mirror of
https://github.com/ManInDark/HabitTrove.git
synced 2026-01-20 22:24:28 +01:00
Initial commit
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
.next
|
||||
node_modules
|
||||
npm-debug.log
|
||||
data
|
||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# customize
|
||||
data/*
|
||||
65
Dockerfile
Normal file
65
Dockerfile
Normal file
@@ -0,0 +1,65 @@
|
||||
# syntax=docker.io/docker/dockerfile:1
|
||||
|
||||
FROM node:18-alpine AS base
|
||||
|
||||
# Install dependencies only when needed
|
||||
FROM base AS deps
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* .npmrc* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
# use --force flag until all deps supports react19
|
||||
elif [ -f package-lock.json ]; then npm ci --force; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn run build; \
|
||||
elif [ -f package-lock.json ]; then npm run build; \
|
||||
elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Create data directory and set permissions
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
661
LICENSE
Normal file
661
LICENSE
Normal file
@@ -0,0 +1,661 @@
|
||||
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||
Version 3, 19 November 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 Affero General Public License is a free, copyleft license for
|
||||
software and other kinds of works, specifically designed to ensure
|
||||
cooperation with the community in the case of network server software.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
our General Public Licenses are 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.
|
||||
|
||||
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.
|
||||
|
||||
Developers that use our General Public Licenses protect your rights
|
||||
with two steps: (1) assert copyright on the software, and (2) offer
|
||||
you this License which gives you legal permission to copy, distribute
|
||||
and/or modify the software.
|
||||
|
||||
A secondary benefit of defending all users' freedom is that
|
||||
improvements made in alternate versions of the program, if they
|
||||
receive widespread use, become available for other developers to
|
||||
incorporate. Many developers of free software are heartened and
|
||||
encouraged by the resulting cooperation. However, in the case of
|
||||
software used on network servers, this result may fail to come about.
|
||||
The GNU General Public License permits making a modified version and
|
||||
letting the public access it on a server without ever releasing its
|
||||
source code to the public.
|
||||
|
||||
The GNU Affero General Public License is designed specifically to
|
||||
ensure that, in such cases, the modified source code becomes available
|
||||
to the community. It requires the operator of a network server to
|
||||
provide the source code of the modified version running there to the
|
||||
users of that server. Therefore, public use of a modified version, on
|
||||
a publicly accessible server, gives the public access to the source
|
||||
code of the modified version.
|
||||
|
||||
An older license, called the Affero General Public License and
|
||||
published by Affero, was designed to accomplish similar goals. This is
|
||||
a different license, not a version of the Affero GPL, but Affero has
|
||||
released a new version of the Affero GPL which permits relicensing under
|
||||
this license.
|
||||
|
||||
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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, if you modify the
|
||||
Program, your modified version must prominently offer all users
|
||||
interacting with it remotely through a computer network (if your version
|
||||
supports such interaction) an opportunity to receive the Corresponding
|
||||
Source of your version by providing access to the Corresponding Source
|
||||
from a network server at no charge, through some standard or customary
|
||||
means of facilitating copying of software. This Corresponding Source
|
||||
shall include the Corresponding Source for any work covered by version 3
|
||||
of the GNU General Public License that is incorporated pursuant to the
|
||||
following paragraph.
|
||||
|
||||
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 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 work with which it is combined will remain governed by version
|
||||
3 of the GNU General Public License.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU Affero 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 Affero 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 Affero 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 Affero 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 Affero 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 Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero 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 your software can interact with users remotely through a computer
|
||||
network, you should also make sure that it provides a way for users to
|
||||
get its source. For example, if your program is a web application, its
|
||||
interface could display a "Source" link that leads users to an archive
|
||||
of the code. There are many ways you could offer source, and different
|
||||
solutions will be better for different programs; see section 13 for the
|
||||
specific requirements.
|
||||
|
||||
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 AGPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
94
README.md
Normal file
94
README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# HabitTrove
|
||||
|
||||
HabitTrove is a gamified habit tracking application that helps you build and maintain positive habits by rewarding you with coins, which you can use to exchange for rewards.
|
||||
|
||||
> **⚠️ Important:** HabitTrove is currently in beta. Please regularly backup your `data/` directory to prevent any potential data loss.
|
||||
|
||||
## Try the Demo
|
||||
|
||||
Want to try HabitTrove before installing? Visit the public [demo instance](https://habittrove.app.enting.org) to experience all features without any setup required. (do not store personal info. Data on the demo instance is reset daily)
|
||||
|
||||
## Features
|
||||
|
||||
- 🎯 Create and track daily habits
|
||||
- 🏆 Earn coins for completing habits
|
||||
- 💰 Create a wishlist of rewards to redeem with earned coins
|
||||
- 📊 View your habit completion streaks and statistics
|
||||
- 📅 Calendar heatmap to visualize your progress (WIP)
|
||||
- 🌙 Dark mode support (WIP)
|
||||
- 📲 Progressive Web App (PWA) support (Planned)
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20 or later
|
||||
- npm package manager
|
||||
|
||||
### Installation
|
||||
|
||||
1. Clone the repository:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/dohsimpson/habittrove.git
|
||||
cd habittrove
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
|
||||
```bash
|
||||
npm install --force
|
||||
```
|
||||
|
||||
3. Start the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open [http://localhost:3000](http://localhost:3000) with your browser to see the application.
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Creating Habits**: Click the "Add Habit" button to create a new habit. Set a name, description, and coin reward.
|
||||
|
||||
2. **Tracking Habits**: Mark habits as complete on your dashboard. Each completion earns you the specified coins.
|
||||
|
||||
3. **Wishlist**: Add rewards to your wishlist that you can redeem with earned coins.
|
||||
|
||||
4. **Statistics**: View your progress through the heatmap and streak counters.
|
||||
|
||||
## Docker Deployment
|
||||
|
||||
For production deployment, you can use Docker. The application data will be persisted in the `data` directory.
|
||||
|
||||
```bash
|
||||
# Build the Docker image
|
||||
npm run docker-build
|
||||
|
||||
# Run the Docker container
|
||||
npm run docker-run
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! We appreciate both:
|
||||
|
||||
- Issue submissions for bug reports and feature requests
|
||||
- Pull Requests for code contributions
|
||||
|
||||
For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
|
||||
3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. Push to the branch (`git push origin feature/AmazingFeature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the GNU Affero General Public License v3.0 - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions, please file an issue on the GitHub repository.
|
||||
140
app/actions/data.ts
Normal file
140
app/actions/data.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
'use server'
|
||||
|
||||
import fs from 'fs/promises'
|
||||
import path from 'path'
|
||||
import { HabitsData, CoinsData, CoinTransaction, TransactionType, WishlistItemType } from '@/lib/types'
|
||||
|
||||
type DataType = 'wishlist' | 'habits' | 'coins'
|
||||
|
||||
async function ensureDataDir() {
|
||||
const dataDir = path.join(process.cwd(), 'data')
|
||||
try {
|
||||
await fs.access(dataDir)
|
||||
} catch {
|
||||
await fs.mkdir(dataDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadData<T>(type: DataType): Promise<T> {
|
||||
try {
|
||||
await ensureDataDir()
|
||||
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
|
||||
|
||||
try {
|
||||
await fs.access(filePath)
|
||||
} catch {
|
||||
// File doesn't exist, create it with initial data
|
||||
const initialData = type === 'wishlist'
|
||||
? { items: [] }
|
||||
: type === 'habits'
|
||||
? { habits: [] }
|
||||
: { balance: 0, transactions: [] }
|
||||
|
||||
await fs.writeFile(filePath, JSON.stringify(initialData, null, 2))
|
||||
return initialData as T
|
||||
}
|
||||
|
||||
// File exists, read and return its contents
|
||||
const data = await fs.readFile(filePath, 'utf8')
|
||||
const jsonData = JSON.parse(data)
|
||||
return type === 'wishlist' ? jsonData.items : jsonData
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${type} data:`, error)
|
||||
if (type === 'wishlist') return [] as T
|
||||
if (type === 'habits') return { habits: [] } as T
|
||||
if (type === 'coins') return { balance: 0, transactions: [] } as T
|
||||
return {} as T
|
||||
}
|
||||
}
|
||||
|
||||
async function saveData<T>(type: DataType, data: T): Promise<void> {
|
||||
try {
|
||||
await ensureDataDir()
|
||||
const filePath = path.join(process.cwd(), 'data', `${type}.json`)
|
||||
const saveData = type === 'wishlist' ? { items: data } : data
|
||||
await fs.writeFile(filePath, JSON.stringify(saveData, null, 2))
|
||||
} catch (error) {
|
||||
console.error(`Error saving ${type} data:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
// Wishlist specific functions
|
||||
export async function loadWishlistItems(): Promise<WishlistItemType[]> {
|
||||
return loadData<WishlistItemType[]>('wishlist')
|
||||
}
|
||||
|
||||
export async function saveWishlistItems(items: WishlistItemType[]): Promise<void> {
|
||||
return saveData('wishlist', items)
|
||||
}
|
||||
|
||||
// Habits specific functions
|
||||
export async function loadHabitsData(): Promise<HabitsData> {
|
||||
return loadData<HabitsData>('habits')
|
||||
}
|
||||
|
||||
export async function saveHabitsData(data: HabitsData): Promise<void> {
|
||||
return saveData('habits', data)
|
||||
}
|
||||
|
||||
// Coins specific functions
|
||||
export async function loadCoinsData(): Promise<CoinsData> {
|
||||
try {
|
||||
return await loadData<CoinsData>('coins')
|
||||
} catch {
|
||||
return { balance: 0, transactions: [] }
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveCoinsData(data: CoinsData): Promise<void> {
|
||||
return saveData('coins', data)
|
||||
}
|
||||
|
||||
export async function addCoins(
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
): Promise<CoinsData> {
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
amount,
|
||||
type,
|
||||
description,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(relatedItemId && { relatedItemId })
|
||||
}
|
||||
|
||||
const newData: CoinsData = {
|
||||
balance: data.balance + amount,
|
||||
transactions: [newTransaction, ...data.transactions]
|
||||
}
|
||||
|
||||
await saveCoinsData(newData)
|
||||
return newData
|
||||
}
|
||||
|
||||
export async function removeCoins(
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
): Promise<CoinsData> {
|
||||
const data = await loadCoinsData()
|
||||
const newTransaction: CoinTransaction = {
|
||||
id: crypto.randomUUID(),
|
||||
amount: -amount,
|
||||
type,
|
||||
description,
|
||||
timestamp: new Date().toISOString(),
|
||||
...(relatedItemId && { relatedItemId })
|
||||
}
|
||||
|
||||
const newData: CoinsData = {
|
||||
balance: Math.max(0, data.balance - amount),
|
||||
transactions: [newTransaction, ...data.transactions]
|
||||
}
|
||||
|
||||
await saveCoinsData(newData)
|
||||
return newData
|
||||
}
|
||||
11
app/calendar/page.tsx
Normal file
11
app/calendar/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitCalendar from '@/components/HabitCalendar'
|
||||
|
||||
export default function CalendarPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<HabitCalendar />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
10
app/coins/page.tsx
Normal file
10
app/coins/page.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import CoinsManager from '@/components/CoinsManager'
|
||||
|
||||
export default function CoinsPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<CoinsManager />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
BIN
app/favicon.ico
Normal file
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
73
app/globals.css
Normal file
73
app/globals.css
Normal file
@@ -0,0 +1,73 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 0 0% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 3.9%;
|
||||
--primary: 0 0% 9%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96.1%;
|
||||
--secondary-foreground: 0 0% 9%;
|
||||
--muted: 0 0% 96.1%;
|
||||
--muted-foreground: 0 0% 45.1%;
|
||||
--accent: 0 0% 96.1%;
|
||||
--accent-foreground: 0 0% 9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 89.8%;
|
||||
--input: 0 0% 89.8%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--chart-1: 12 76% 61%;
|
||||
--chart-2: 173 58% 39%;
|
||||
--chart-3: 197 37% 24%;
|
||||
--chart-4: 43 74% 66%;
|
||||
--chart-5: 27 87% 67%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
}
|
||||
11
app/habits/page.tsx
Normal file
11
app/habits/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import HabitList from '@/components/HabitList'
|
||||
|
||||
export default function HabitsPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<HabitList />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
38
app/layout.tsx
Normal file
38
app/layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import './globals.css'
|
||||
import { Inter } from 'next/font/google'
|
||||
import { DM_Sans } from 'next/font/google'
|
||||
import { Toaster } from '@/components/ui/toaster'
|
||||
// Inter (clean, modern, excellent readability)
|
||||
const inter = Inter({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700']
|
||||
})
|
||||
//
|
||||
// Clean and contemporary
|
||||
const dmSans = DM_Sans({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '500', '600', '700']
|
||||
})
|
||||
|
||||
const activeFont = dmSans
|
||||
|
||||
export const metadata = {
|
||||
title: 'HabitTrove',
|
||||
description: 'Track your habits and get rewarded',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={activeFont.className}>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
11
app/page.tsx
Normal file
11
app/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import Dashboard from '@/components/Dashboard'
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Layout>
|
||||
<Dashboard />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
11
app/wishlist/page.tsx
Normal file
11
app/wishlist/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import Layout from '@/components/Layout'
|
||||
import WishlistManager from '@/components/WishlistManager'
|
||||
|
||||
export default function WishlistPage() {
|
||||
return (
|
||||
<Layout>
|
||||
<WishlistManager />
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
|
||||
21
components.json
Normal file
21
components.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
111
components/AddEditHabitModal.tsx
Normal file
111
components/AddEditHabitModal.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
interface AddEditHabitModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (habit: Omit<Habit, 'id'>) => void
|
||||
habit?: Habit | null
|
||||
}
|
||||
|
||||
export default function AddEditHabitModal({ isOpen, onClose, onSave, habit }: AddEditHabitModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [frequency, setFrequency] = useState<'daily' | 'weekly' | 'monthly'>('daily')
|
||||
const [coinReward, setCoinReward] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (habit) {
|
||||
setName(habit.name)
|
||||
setDescription(habit.description)
|
||||
setFrequency(habit.frequency)
|
||||
setCoinReward(habit.coinReward)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setFrequency('daily')
|
||||
setCoinReward(1)
|
||||
}
|
||||
}, [habit])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({ name, description, frequency, coinReward, completions: habit?.completions || [] })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{habit ? 'Edit Habit' : 'Add New Habit'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="frequency" className="text-right">
|
||||
Frequency
|
||||
</Label>
|
||||
<Select value={frequency} onValueChange={(value: 'daily' | 'weekly' | 'monthly') => setFrequency(value)}>
|
||||
<SelectTrigger className="col-span-3">
|
||||
<SelectValue placeholder="Select frequency" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="daily">Daily</SelectItem>
|
||||
<SelectItem value="weekly">Weekly</SelectItem>
|
||||
<SelectItem value="monthly">Monthly</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="coinReward" className="text-right">
|
||||
Coin Reward
|
||||
</Label>
|
||||
<Input
|
||||
id="coinReward"
|
||||
type="number"
|
||||
value={coinReward}
|
||||
onChange={(e) => setCoinReward(parseInt(e.target.value === "" ? "0" : e.target.value))}
|
||||
className="col-span-3"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{habit ? 'Save Changes' : 'Add Habit'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
92
components/AddEditWishlistItemModal.tsx
Normal file
92
components/AddEditWishlistItemModal.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
|
||||
interface AddEditWishlistItemModalProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onSave: (item: Omit<WishlistItemType, 'id'>) => void
|
||||
item?: WishlistItemType | null
|
||||
}
|
||||
|
||||
export default function AddEditWishlistItemModal({ isOpen, onClose, onSave, item }: AddEditWishlistItemModalProps) {
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [coinCost, setCoinCost] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setName(item.name)
|
||||
setDescription(item.description)
|
||||
setCoinCost(item.coinCost)
|
||||
} else {
|
||||
setName('')
|
||||
setDescription('')
|
||||
setCoinCost(1)
|
||||
}
|
||||
}, [item])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({ name, description, coinCost })
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{item ? 'Edit Reward' : 'Add New Reward'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="col-span-3"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="description" className="text-right">
|
||||
Description
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="coinCost" className="text-right">
|
||||
Coin Cost
|
||||
</Label>
|
||||
<Input
|
||||
id="coinCost"
|
||||
type="number"
|
||||
value={coinCost}
|
||||
onChange={(e) => setCoinCost(parseInt(e.target.value === "" ? "0" : e.target.value))}
|
||||
className="col-span-3"
|
||||
min={1}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit">{item ? 'Save Changes' : 'Add Reward'}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
19
components/CoinBalance.tsx
Normal file
19
components/CoinBalance.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Coins } from 'lucide-react'
|
||||
|
||||
export default function CoinBalance({ coinBalance }: { coinBalance: number }) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Coin Balance</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center">
|
||||
<Coins className="h-12 w-12 text-yellow-400 mr-4" />
|
||||
<span className="text-4xl font-bold">{coinBalance}</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
229
components/CoinsManager.tsx
Normal file
229
components/CoinsManager.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { History } from 'lucide-react'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { format } from 'date-fns'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default function CoinsManager() {
|
||||
const { balance, transactions, addAmount, removeAmount } = useCoins()
|
||||
const DEFAULT_AMOUNT = '0'
|
||||
const [amount, setAmount] = useState(DEFAULT_AMOUNT)
|
||||
|
||||
const handleAddCoins = async () => {
|
||||
const data = await addAmount(Number(amount), "Manual addition")
|
||||
if (data) {
|
||||
setAmount(DEFAULT_AMOUNT)
|
||||
toast({ title: "Success", description: `Added ${amount} coins` })
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveCoins = async () => {
|
||||
const data = await removeAmount(Math.abs(Number(amount)), "Manual removal")
|
||||
if (data) {
|
||||
setAmount(DEFAULT_AMOUNT)
|
||||
toast({ title: "Success", description: `Removed ${amount} coins` })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Coins Management</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span className="text-2xl animate-bounce hover:animate-none cursor-default">🪙</span>
|
||||
<div>
|
||||
<div className="text-sm font-normal text-muted-foreground">Current Balance</div>
|
||||
<div className="text-3xl font-bold">{balance} coins</div>
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 text-lg"
|
||||
onClick={() => setAmount(prev => (Number(prev) - 1).toString())}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<div className="relative w-32">
|
||||
<Input
|
||||
type="number"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
className="text-center text-xl font-medium h-12"
|
||||
/>
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground">
|
||||
🪙
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="h-10 w-10 text-lg"
|
||||
onClick={() => setAmount(prev => (Number(prev) + 1).toString())}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => {
|
||||
const numAmount = Number(amount);
|
||||
if (numAmount > 0) {
|
||||
handleAddCoins();
|
||||
} else if (numAmount < 0) {
|
||||
handleRemoveCoins();
|
||||
}
|
||||
}}
|
||||
className="w-full h-14 transition-colors flex items-center justify-center font-medium"
|
||||
variant="default"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{Number(amount) >= 0 ? 'Add Coins' : 'Remove Coins'}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="p-4 rounded-lg bg-green-100 dark:bg-green-900">
|
||||
<div className="text-sm text-green-800 dark:text-green-100 mb-1">Total Earned</div>
|
||||
<div className="text-2xl font-bold text-green-900 dark:text-green-50">
|
||||
{transactions
|
||||
.filter(t => {
|
||||
if (t.type === 'HABIT_COMPLETION' && t.relatedItemId) {
|
||||
return !transactions.some(undoT =>
|
||||
undoT.type === 'HABIT_UNDO' &&
|
||||
undoT.relatedItemId === t.relatedItemId
|
||||
)
|
||||
}
|
||||
return t.amount > 0 && t.type !== 'HABIT_UNDO'
|
||||
})
|
||||
.reduce((sum, t) => sum + t.amount, 0)
|
||||
} 🪙
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-red-100 dark:bg-red-900">
|
||||
<div className="text-sm text-red-800 dark:text-red-100 mb-1">Total Spent</div>
|
||||
<div className="text-2xl font-bold text-red-900 dark:text-red-50">
|
||||
{Math.abs(
|
||||
transactions
|
||||
.filter(t => t.type === 'WISH_REDEMPTION' || t.type === 'MANUAL_ADJUSTMENT')
|
||||
.reduce((sum, t) => sum + (t.amount < 0 ? t.amount : 0), 0)
|
||||
)} 🪙
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<div className="text-sm text-blue-800 dark:text-blue-100 mb-1">Today's Transactions</div>
|
||||
<div className="text-2xl font-bold text-blue-900 dark:text-blue-50">
|
||||
{transactions.filter(t =>
|
||||
new Date(t.timestamp).toDateString() === new Date().toDateString()
|
||||
).length} 📊
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex justify-between items-center">
|
||||
<span>Transaction History</span>
|
||||
<span className="text-sm font-normal text-muted-foreground">
|
||||
Total: {transactions.length} transactions
|
||||
</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{transactions.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={History}
|
||||
title="No transactions yet"
|
||||
description="Your transaction history will appear here once you start earning or spending coins"
|
||||
/>
|
||||
) : (
|
||||
transactions.map((transaction) => {
|
||||
const getBadgeStyles = () => {
|
||||
switch (transaction.type) {
|
||||
case 'HABIT_COMPLETION':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100'
|
||||
case 'HABIT_UNDO':
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-100'
|
||||
case 'WISH_REDEMPTION':
|
||||
return 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-100'
|
||||
case 'MANUAL_ADJUSTMENT':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-100'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-100'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={transaction.id}
|
||||
className="flex justify-between items-center p-3 border rounded hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{transaction.relatedItemId ? (
|
||||
<Link
|
||||
href={`${transaction.type === 'WISH_REDEMPTION' ? '/wishlist' : '/habits'}?highlight=${transaction.relatedItemId}`}
|
||||
className="font-medium hover:underline"
|
||||
scroll={true}
|
||||
>
|
||||
{transaction.description}
|
||||
</Link>
|
||||
) : (
|
||||
<p className="font-medium">{transaction.description}</p>
|
||||
)}
|
||||
<span
|
||||
className={`text-xs px-2 py-1 rounded-full ${getBadgeStyles()}`}
|
||||
>
|
||||
{transaction.type.split('_').join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(new Date(transaction.timestamp), 'PPpp')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono ${transaction.amount >= 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-red-600 dark:text-red-400'
|
||||
}`}
|
||||
>
|
||||
{transaction.amount >= 0 ? '+' : ''}{transaction.amount}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
components/ConfirmDialog.tsx
Normal file
49
components/ConfirmDialog.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onConfirm: () => void
|
||||
title: string
|
||||
message: string
|
||||
confirmText?: string
|
||||
cancelText?: string
|
||||
}
|
||||
|
||||
export default function ConfirmDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
message,
|
||||
confirmText = "Confirm",
|
||||
cancelText = "Cancel"
|
||||
}: ConfirmDialogProps) {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p>{message}</p>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{cancelText}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
187
components/DailyOverview.tsx
Normal file
187
components/DailyOverview.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { Circle, Coins, ArrowRight, CircleCheck, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
interface UpcomingItemsProps {
|
||||
habits: Habit[]
|
||||
wishlistItems: WishlistItemType[]
|
||||
coinBalance: number
|
||||
onComplete: (habit: Habit) => void
|
||||
onUndo: (habit: Habit) => void
|
||||
}
|
||||
|
||||
export default function DailyOverview({
|
||||
habits,
|
||||
wishlistItems,
|
||||
coinBalance,
|
||||
onComplete,
|
||||
onUndo
|
||||
}: UpcomingItemsProps) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const todayCompletions = habits.filter(habit =>
|
||||
habit.completions.includes(today)
|
||||
)
|
||||
|
||||
// Filter daily habits
|
||||
const dailyHabits = habits.filter(habit => habit.frequency === 'daily')
|
||||
|
||||
// Get achievable wishlist items sorted by coin cost
|
||||
const achievableWishlistItems = wishlistItems
|
||||
.filter(item => item.coinCost > coinBalance)
|
||||
.sort((a, b) => a.coinCost - b.coinCost)
|
||||
|
||||
const [expandedHabits, setExpandedHabits] = useState(false)
|
||||
const [expandedWishlist, setExpandedWishlist] = useState(false)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Today's Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h3 className="font-semibold">Daily Habits</h3>
|
||||
<Badge variant="secondary">
|
||||
{todayCompletions.length}/{dailyHabits.length} Complete
|
||||
</Badge>
|
||||
</div>
|
||||
<ul className={`grid gap-2 transition-all duration-300 ease-in-out ${expandedHabits ? 'max-h-[500px] opacity-100' : 'max-h-[200px] opacity-100'} overflow-hidden`}>
|
||||
{dailyHabits
|
||||
.sort((a, b) => {
|
||||
const aCompleted = todayCompletions.includes(a);
|
||||
const bCompleted = todayCompletions.includes(b);
|
||||
return aCompleted === bCompleted ? 0 : aCompleted ? 1 : -1;
|
||||
})
|
||||
.slice(0, expandedHabits ? undefined : 3)
|
||||
.map((habit) => {
|
||||
const isCompleted = todayCompletions.includes(habit)
|
||||
return (
|
||||
<li
|
||||
key={habit.id}
|
||||
className={`flex items-center justify-between text-sm p-2 rounded-md
|
||||
${isCompleted ? 'bg-secondary/50' : 'bg-secondary/20'}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
if (isCompleted) {
|
||||
onUndo(habit);
|
||||
} else {
|
||||
onComplete(habit);
|
||||
}
|
||||
}}
|
||||
className="hover:opacity-70 transition-opacity"
|
||||
>
|
||||
{isCompleted ? (
|
||||
<CircleCheck className="h-4 w-4 text-green-500" />
|
||||
) : (
|
||||
<Circle className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
<span className={isCompleted ? 'line-through' : ''}>
|
||||
{habit.name}
|
||||
</span>
|
||||
</span>
|
||||
<span className="flex items-center text-xs text-muted-foreground">
|
||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
||||
{habit.coinReward}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpandedHabits(!expandedHabits)}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expandedHabits ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show all
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/habits"
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
View
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{achievableWishlistItems.length > 0 && (
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Wishlist Goals</h3>
|
||||
<div className={`space-y-3 transition-all duration-300 ease-in-out ${expandedWishlist ? 'max-h-[500px]' : 'max-h-[200px]'} overflow-hidden`}>
|
||||
{achievableWishlistItems
|
||||
.slice(0, expandedWishlist ? undefined : 1)
|
||||
.map((item) => (
|
||||
<div key={item.id} className="bg-secondary/20 p-3 rounded-md">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm">{item.name}</span>
|
||||
<span className="text-xs flex items-center">
|
||||
<Coins className="h-3 w-3 text-yellow-400 mr-1" />
|
||||
{item.coinCost}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={(coinBalance / item.coinCost) * 100}
|
||||
className="h-2"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{item.coinCost - coinBalance} coins to go
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setExpandedWishlist(!expandedWishlist)}
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
{expandedWishlist ? (
|
||||
<>
|
||||
Show less
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Show all
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
href="/wishlist"
|
||||
className="text-sm text-muted-foreground hover:text-primary flex items-center gap-1"
|
||||
>
|
||||
View
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
55
components/Dashboard.tsx
Normal file
55
components/Dashboard.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import { loadCoinsData } from '@/app/actions/data'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { useWishlist } from '@/hooks/useWishlist'
|
||||
import { useEffect, useState } from 'react'
|
||||
import CoinBalance from './CoinBalance'
|
||||
import DailyOverview from './DailyOverview'
|
||||
import HabitOverview from './HabitOverview'
|
||||
import HabitStreak from './HabitStreak'
|
||||
|
||||
export default function Dashboard() {
|
||||
const { habits, completeHabit, undoComplete } = useHabits()
|
||||
const [coinBalance, setCoinBalance] = useState(0)
|
||||
const { wishlistItems } = useWishlist()
|
||||
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
const coinsData = await loadCoinsData()
|
||||
setCoinBalance(coinsData.balance)
|
||||
}
|
||||
loadData()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Dashboard</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<CoinBalance coinBalance={coinBalance} />
|
||||
{/* <HabitOverview /> */}
|
||||
<HabitStreak habits={habits} />
|
||||
<DailyOverview
|
||||
wishlistItems={wishlistItems}
|
||||
habits={habits}
|
||||
coinBalance={coinBalance}
|
||||
onComplete={async (habit) => {
|
||||
const newBalance = await completeHabit(habit)
|
||||
if (newBalance !== null) {
|
||||
setCoinBalance(newBalance)
|
||||
}
|
||||
}}
|
||||
onUndo={async (habit) => {
|
||||
const newBalance = await undoComplete(habit)
|
||||
if (newBalance !== null) {
|
||||
setCoinBalance(newBalance)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* <HabitHeatmap habits={habits} /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
17
components/EmptyState.tsx
Normal file
17
components/EmptyState.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon: LucideIcon
|
||||
title: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export default function EmptyState({ icon: Icon, title, description }: EmptyStateProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center p-8 text-center">
|
||||
<Icon className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<h3 className="text-lg font-semibold">{title}</h3>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
components/HabitCalendar.tsx
Normal file
88
components/HabitCalendar.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
import { loadHabitsData } from '@/app/actions/data'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export default function HabitCalendar() {
|
||||
const [selectedDate, setSelectedDate] = useState<Date | undefined>(new Date())
|
||||
const [habits, setHabits] = useState<Habit[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabitsData()
|
||||
}, [])
|
||||
|
||||
const fetchHabitsData = async () => {
|
||||
const data = await loadHabitsData()
|
||||
setHabits(data.habits)
|
||||
}
|
||||
|
||||
const getHabitsForDate = (date: Date) => {
|
||||
const dateString = date.toISOString().split('T')[0]
|
||||
return habits.filter(habit =>
|
||||
habit.completions.includes(dateString)
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Habit Calendar</h1>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Calendar</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={setSelectedDate}
|
||||
className="rounded-md border"
|
||||
modifiers={{
|
||||
completed: (date) => getHabitsForDate(date).length > 0,
|
||||
}}
|
||||
modifiersClassNames={{
|
||||
completed: 'bg-green-100 text-green-800 font-bold',
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{selectedDate ? (
|
||||
<>Habits for {selectedDate.toLocaleDateString()}</>
|
||||
) : (
|
||||
'Select a date'
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{selectedDate && (
|
||||
<ul className="space-y-2">
|
||||
{habits.map((habit) => {
|
||||
const isCompleted = getHabitsForDate(selectedDate).some(h => h.id === habit.id)
|
||||
return (
|
||||
<li key={habit.id} className="flex items-center justify-between">
|
||||
<span>{habit.name}</span>
|
||||
{isCompleted ? (
|
||||
<Badge variant="default">Completed</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Not Completed</Badge>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
58
components/HabitHeatmap.tsx
Normal file
58
components/HabitHeatmap.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
|
||||
import HeatMap from '@uiw/react-heat-map'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
interface HabitHeatmapProps {
|
||||
habits: Habit[]
|
||||
}
|
||||
|
||||
export default function HabitHeatmap({ habits }: HabitHeatmapProps) {
|
||||
// Aggregate all habit completions into a count per day
|
||||
const completionCounts = habits.reduce((acc: { [key: string]: number }, habit) => {
|
||||
habit.completions.forEach(date => {
|
||||
// Convert date format from ISO (YYYY-MM-DD) to YYYY/MM/DD for the heatmap
|
||||
const formattedDate = date.replace(/-/g, '/')
|
||||
acc[formattedDate] = (acc[formattedDate] || 0) + 1
|
||||
})
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
// Convert to the format expected by the heatmap
|
||||
const value = Object.entries(completionCounts).map(([date, count]) => ({
|
||||
date,
|
||||
count
|
||||
}))
|
||||
|
||||
// Get start date (30 days ago)
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
|
||||
return (
|
||||
<div className="p-4 bg-white rounded-lg shadow">
|
||||
<h2 className="text-lg font-semibold mb-4">Habit Completion Heatmap</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<HeatMap
|
||||
value={value}
|
||||
startDate={startDate}
|
||||
width={500}
|
||||
style={{ color: '#047857' }}
|
||||
panelColors={[
|
||||
'#f0fdf4', // Very light green
|
||||
'#dcfce7', // Light green
|
||||
'#bbf7d0', // Medium light green
|
||||
'#86efac', // Medium green
|
||||
'#4ade80', // Bright green
|
||||
'#22c55e' // Dark green
|
||||
]}
|
||||
rectProps={{ rx: 3 }} // Rounded corners
|
||||
rectRender={(props, data) => {
|
||||
return (
|
||||
<title>{`${data.date}: ${data.count || 0} habits completed`}</title>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
components/HabitItem.tsx
Normal file
90
components/HabitItem.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Habit } from '@/lib/types'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Check, Undo2 } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface HabitItemProps {
|
||||
habit: Habit
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onComplete: () => void
|
||||
onUndo: () => void
|
||||
}
|
||||
|
||||
export default function HabitItem({ habit, onEdit, onDelete, onComplete, onUndo }: HabitItemProps) {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const isCompletedToday = habit.completions?.includes(today)
|
||||
const [isHighlighted, setIsHighlighted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const highlightId = params.get('highlight')
|
||||
|
||||
if (highlightId === habit.id) {
|
||||
setIsHighlighted(true)
|
||||
// Scroll the element into view after a short delay to ensure rendering
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`habit-${habit.id}`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, 100)
|
||||
// Remove highlight after animation
|
||||
const timer = setTimeout(() => setIsHighlighted(false), 2000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [habit.id])
|
||||
|
||||
return (
|
||||
<Card
|
||||
id={`habit-${habit.id}`}
|
||||
className={`transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{habit.name}</CardTitle>
|
||||
<CardDescription>{habit.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-gray-500">Frequency: {habit.frequency}</p>
|
||||
<div className="flex items-center mt-2">
|
||||
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
||||
<span className="text-sm font-medium">{habit.coinReward} coins per completion</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="mr-2">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={isCompletedToday ? "secondary" : "default"}
|
||||
size="sm"
|
||||
onClick={onComplete}
|
||||
disabled={isCompletedToday}
|
||||
>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
{isCompletedToday ? "Completed" : "Complete"}
|
||||
</Button>
|
||||
{isCompletedToday && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onUndo}
|
||||
>
|
||||
<Undo2 />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
89
components/HabitList.tsx
Normal file
89
components/HabitList.tsx
Normal file
@@ -0,0 +1,89 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useHabits } from '@/hooks/useHabits'
|
||||
import { Plus, ListTodo } from 'lucide-react'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import HabitItem from './HabitItem'
|
||||
import AddEditHabitModal from './AddEditHabitModal'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export default function HabitList() {
|
||||
const { habits, addHabit, editHabit, deleteHabit, completeHabit, undoComplete } = useHabits()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingHabit, setEditingHabit] = useState<Habit | null>(null)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, habitId: string | null }>({
|
||||
isOpen: false,
|
||||
habitId: null
|
||||
})
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">My Habits</h1>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Habit
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{habits.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
<EmptyState
|
||||
icon={ListTodo}
|
||||
title="No habits yet"
|
||||
description="Create your first habit to start tracking your progress"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
habits.map((habit) => (
|
||||
<HabitItem
|
||||
key={habit.id}
|
||||
habit={habit}
|
||||
onEdit={() => {
|
||||
setEditingHabit(habit)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
onDelete={() => setDeleteConfirmation({ isOpen: true, habitId: habit.id })}
|
||||
onComplete={() => completeHabit(habit)}
|
||||
onUndo={() => undoComplete(habit)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<AddEditHabitModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setEditingHabit(null)
|
||||
}}
|
||||
onSave={async (habit) => {
|
||||
if (editingHabit) {
|
||||
await editHabit(editingHabit)
|
||||
} else {
|
||||
await addHabit(habit)
|
||||
}
|
||||
setIsModalOpen(false)
|
||||
setEditingHabit(null)
|
||||
}}
|
||||
habit={editingHabit}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmation.isOpen}
|
||||
onClose={() => setDeleteConfirmation({ isOpen: false, habitId: null })}
|
||||
onConfirm={() => {
|
||||
if (deleteConfirmation.habitId) {
|
||||
deleteHabit(deleteConfirmation.habitId)
|
||||
}
|
||||
setDeleteConfirmation({ isOpen: false, habitId: null })
|
||||
}}
|
||||
title="Delete Habit"
|
||||
message="Are you sure you want to delete this habit? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
64
components/HabitOverview.tsx
Normal file
64
components/HabitOverview.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { BarChart } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { loadHabitsData } from '@/app/actions/data'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export default function HabitOverview() {
|
||||
const [habits, setHabits] = useState<Habit[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
const fetchHabits = async () => {
|
||||
const data = await loadHabitsData()
|
||||
setHabits(data.habits)
|
||||
}
|
||||
fetchHabits()
|
||||
}, [])
|
||||
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
|
||||
const completedToday = habits.filter(habit =>
|
||||
habit.completions.includes(today)
|
||||
).length
|
||||
|
||||
const habitsByFrequency = habits.reduce((acc, habit) => ({
|
||||
...acc,
|
||||
[habit.frequency]: (acc[habit.frequency] || 0) + 1
|
||||
}), {} as Record<string, number>)
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Habit Overview</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* Today's Progress */}
|
||||
<div className="bg-secondary/20 p-4 rounded-lg">
|
||||
<h3 className="font-semibold mb-2">Today's Progress</h3>
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{completedToday}/{habits.length} completed</span>
|
||||
<BarChart className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Frequency Breakdown */}
|
||||
<div>
|
||||
<h3 className="font-semibold mb-2">Habit Frequency</h3>
|
||||
<div className="space-y-2">
|
||||
{Object.entries(habitsByFrequency).map(([frequency, count]) => (
|
||||
<div key={frequency} className="flex items-center justify-between text-sm">
|
||||
<span className="capitalize">{frequency}</span>
|
||||
<span className="bg-secondary/30 px-2 py-1 rounded">
|
||||
{count} habits
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
61
components/HabitStreak.tsx
Normal file
61
components/HabitStreak.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import { Habit } from '@/lib/types'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
|
||||
|
||||
interface HabitStreakProps {
|
||||
habits: Habit[]
|
||||
}
|
||||
|
||||
export default function HabitStreak({ habits }: HabitStreakProps) {
|
||||
// Get the last 30 days of data
|
||||
const dates = Array.from({ length: 30 }, (_, i) => {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - i)
|
||||
return d.toISOString().split('T')[0]
|
||||
}).reverse()
|
||||
|
||||
// Count completed habits per day
|
||||
const completions = dates.map(date => ({
|
||||
date: new Date(date).toLocaleDateString(),
|
||||
completed: habits.filter(habit =>
|
||||
habit.completions.includes(date)
|
||||
).length
|
||||
}))
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Daily Habit Completion Streak</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="w-full aspect-[2/1]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart
|
||||
data={completions}
|
||||
margin={{
|
||||
top: 5,
|
||||
right: 30,
|
||||
left: 20,
|
||||
bottom: 5,
|
||||
}}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="date" />
|
||||
<YAxis />
|
||||
<Tooltip formatter={(value) => [`${value} habits`, 'Completed']} />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey="completed"
|
||||
stroke="#14b8a6"
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
28
components/Header.tsx
Normal file
28
components/Header.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { Bell, Settings } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Logo } from '@/components/Logo'
|
||||
|
||||
interface HeaderProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Header({ className }: HeaderProps) {
|
||||
return (
|
||||
<header className={`border-b bg-white dark:bg-gray-800 shadow-sm ${className || ''}`}>
|
||||
<div className="mx-auto py-4 px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Logo />
|
||||
{/* <div className="flex items-center"> */}
|
||||
{/* <Button variant="ghost" size="icon" className="mr-2"> */}
|
||||
{/* <Bell className="h-5 w-5" /> */}
|
||||
{/* </Button> */}
|
||||
{/* <Button variant="ghost" size="icon"> */}
|
||||
{/* <Settings className="h-5 w-5" /> */}
|
||||
{/* </Button> */}
|
||||
{/* </div> */}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
21
components/Layout.tsx
Normal file
21
components/Layout.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import Header from './Header'
|
||||
import Sidebar from './Sidebar'
|
||||
import MobileNavigation from './MobileNavigation'
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-100 dark:bg-gray-900">
|
||||
<Header className="sticky top-0 z-50" />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<Sidebar />
|
||||
<div className="flex-1 flex flex-col">
|
||||
<main className="flex-1 overflow-x-hidden overflow-y-auto bg-gray-100 dark:bg-gray-900">
|
||||
{children}
|
||||
</main>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
10
components/Logo.tsx
Normal file
10
components/Logo.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Sparkles } from "lucide-react"
|
||||
|
||||
export function Logo() {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<span className="font-bold text-xl">HabitTrove</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
components/MobileNavigation.tsx
Normal file
30
components/MobileNavigation.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: 'Dashboard', href: '/' },
|
||||
{ icon: List, label: 'Habits', href: '/habits' },
|
||||
{ icon: Calendar, label: 'Calendar', href: '/calendar' },
|
||||
{ icon: Gift, label: 'Wishlist', href: '/wishlist' },
|
||||
{ icon: Coins, label: 'Coins', href: '/coins' },
|
||||
]
|
||||
|
||||
export default function MobileNavigation() {
|
||||
return (
|
||||
<nav className="lg:hidden fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-800 shadow-lg">
|
||||
<div className="flex justify-around">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="flex flex-col items-center py-2 text-gray-600 dark:text-gray-300 hover:text-blue-500 dark:hover:text-blue-400"
|
||||
>
|
||||
<item.icon className="h-6 w-6" />
|
||||
<span className="text-xs mt-1">{item.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
40
components/RewardProgress.tsx
Normal file
40
components/RewardProgress.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
|
||||
// This would typically come from your backend or state management
|
||||
const rewards = [
|
||||
{ id: '1', name: 'Day off work', coinCost: 500 },
|
||||
{ id: '2', name: 'Movie night', coinCost: 100 },
|
||||
{ id: '3', name: 'New book', coinCost: 50 },
|
||||
]
|
||||
|
||||
interface RewardProgressProps {
|
||||
coinBalance: number
|
||||
}
|
||||
|
||||
export default function RewardProgress({ coinBalance }: RewardProgressProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Progress Towards Rewards</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{rewards.map((reward) => {
|
||||
const progress = Math.min((coinBalance / reward.coinCost) * 100, 100)
|
||||
return (
|
||||
<div key={reward.id}>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm font-medium">{reward.name}</span>
|
||||
<span className="text-sm font-medium">{coinBalance}/{reward.coinCost} coins</span>
|
||||
</div>
|
||||
<Progress value={progress} className="w-full" />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
36
components/Sidebar.tsx
Normal file
36
components/Sidebar.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import Link from 'next/link'
|
||||
import { Home, Calendar, List, Gift, Coins } from 'lucide-react'
|
||||
|
||||
const navItems = [
|
||||
{ icon: Home, label: 'Dashboard', href: '/' },
|
||||
{ icon: List, label: 'Habits', href: '/habits' },
|
||||
// { icon: Calendar, label: 'Calendar', href: '/calendar' },
|
||||
{ icon: Gift, label: 'Wishlist', href: '/wishlist' },
|
||||
{ icon: Coins, label: 'Coins', href: '/coins' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
return (
|
||||
<div className="hidden lg:flex lg:flex-shrink-0">
|
||||
<div className="flex flex-col w-64">
|
||||
<div className="flex flex-col h-0 flex-1 bg-gray-800">
|
||||
<div className="flex-1 flex flex-col pt-5 pb-4 overflow-y-auto">
|
||||
<nav className="mt-5 flex-1 px-2 space-y-1">
|
||||
{navItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
className="group flex items-center px-2 py-2 text-sm leading-6 font-medium rounded-md text-gray-300 hover:text-white hover:bg-gray-700"
|
||||
>
|
||||
<item.icon className="mr-4 flex-shrink-0 h-6 w-6 text-gray-400" aria-hidden="true" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
69
components/WishlistItem.tsx
Normal file
69
components/WishlistItem.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Coins, Edit, Trash2, Gift } from 'lucide-react'
|
||||
|
||||
interface WishlistItemProps {
|
||||
item: WishlistItemType
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
onRedeem: () => void
|
||||
canRedeem: boolean
|
||||
isHighlighted?: boolean
|
||||
isRecentlyRedeemed?: boolean
|
||||
}
|
||||
|
||||
export default function WishlistItem({
|
||||
item,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRedeem,
|
||||
canRedeem,
|
||||
isHighlighted,
|
||||
isRecentlyRedeemed
|
||||
}: WishlistItemProps) {
|
||||
return (
|
||||
<Card
|
||||
id={`wishlist-${item.id}`}
|
||||
className={`transition-all duration-500 ${isHighlighted ? 'bg-yellow-100 dark:bg-yellow-900' : ''
|
||||
} ${isRecentlyRedeemed ? 'animate-[celebrate_1s_ease-in-out] shadow-lg ring-2 ring-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle>{item.name}</CardTitle>
|
||||
<CardDescription>{item.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center">
|
||||
<Coins className="h-4 w-4 text-yellow-400 mr-1" />
|
||||
<span className="text-sm font-medium">{item.coinCost} coins</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
<CardFooter className="flex justify-between">
|
||||
<div>
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="mr-2">
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant={canRedeem ? "default" : "secondary"}
|
||||
size="sm"
|
||||
onClick={onRedeem}
|
||||
disabled={!canRedeem}
|
||||
className={`transition-all duration-300 ${isRecentlyRedeemed ? 'bg-green-500 hover:bg-green-600' : ''
|
||||
}`}
|
||||
>
|
||||
<Gift className={`h-4 w-4 mr-2 ${isRecentlyRedeemed ? 'animate-spin' : ''
|
||||
}`} />
|
||||
{isRecentlyRedeemed ? 'Redeemed!' : 'Redeem'}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
129
components/WishlistManager.tsx
Normal file
129
components/WishlistManager.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Plus, Gift } from 'lucide-react'
|
||||
import EmptyState from './EmptyState'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import WishlistItem from './WishlistItem'
|
||||
import AddEditWishlistItemModal from './AddEditWishlistItemModal'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { useWishlist } from '@/hooks/useWishlist'
|
||||
|
||||
export default function WishlistManager() {
|
||||
const {
|
||||
wishlistItems,
|
||||
addWishlistItem,
|
||||
editWishlistItem,
|
||||
deleteWishlistItem,
|
||||
redeemWishlistItem,
|
||||
canRedeem
|
||||
} = useWishlist()
|
||||
|
||||
const [highlightedItemId, setHighlightedItemId] = useState<string | null>(null)
|
||||
const [recentlyRedeemedId, setRecentlyRedeemedId] = useState<string | null>(null)
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingItem, setEditingItem] = useState<WishlistItemType | null>(null)
|
||||
const [deleteConfirmation, setDeleteConfirmation] = useState<{ isOpen: boolean, itemId: string | null }>({
|
||||
isOpen: false,
|
||||
itemId: null
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
// Check URL for highlight parameter
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const highlightId = params.get('highlight')
|
||||
if (highlightId) {
|
||||
setHighlightedItemId(highlightId)
|
||||
// Scroll the element into view after a short delay to ensure rendering
|
||||
setTimeout(() => {
|
||||
const element = document.getElementById(`wishlist-${highlightId}`)
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}, 100)
|
||||
// Remove highlight after animation
|
||||
setTimeout(() => setHighlightedItemId(null), 2000)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
const handleRedeem = async (item: WishlistItemType) => {
|
||||
const success = await redeemWishlistItem(item)
|
||||
if (success) {
|
||||
setRecentlyRedeemedId(item.id)
|
||||
setTimeout(() => {
|
||||
setRecentlyRedeemedId(null)
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">My Wishlist</h1>
|
||||
<Button onClick={() => setIsModalOpen(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> Add Reward
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{wishlistItems.length === 0 ? (
|
||||
<div className="col-span-2">
|
||||
<EmptyState
|
||||
icon={Gift}
|
||||
title="Your wishlist is empty"
|
||||
description="Add rewards that you'd like to earn with your coins"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
wishlistItems.map((item) => (
|
||||
<WishlistItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isHighlighted={item.id === highlightedItemId}
|
||||
isRecentlyRedeemed={item.id === recentlyRedeemedId}
|
||||
onEdit={() => {
|
||||
setEditingItem(item)
|
||||
setIsModalOpen(true)
|
||||
}}
|
||||
onDelete={() => setDeleteConfirmation({ isOpen: true, itemId: item.id })}
|
||||
onRedeem={() => handleRedeem(item)}
|
||||
canRedeem={canRedeem(item.coinCost)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<AddEditWishlistItemModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false)
|
||||
setEditingItem(null)
|
||||
}}
|
||||
onSave={(item) => {
|
||||
if (editingItem) {
|
||||
editWishlistItem({ ...item, id: editingItem.id })
|
||||
} else {
|
||||
addWishlistItem(item)
|
||||
}
|
||||
setIsModalOpen(false)
|
||||
setEditingItem(null)
|
||||
}}
|
||||
item={editingItem}
|
||||
/>
|
||||
<ConfirmDialog
|
||||
isOpen={deleteConfirmation.isOpen}
|
||||
onClose={() => setDeleteConfirmation({ isOpen: false, itemId: null })}
|
||||
onConfirm={() => {
|
||||
if (deleteConfirmation.itemId) {
|
||||
deleteWishlistItem(deleteConfirmation.itemId)
|
||||
}
|
||||
setDeleteConfirmation({ isOpen: false, itemId: null })
|
||||
}}
|
||||
title="Delete Reward"
|
||||
message="Are you sure you want to delete this reward? This action cannot be undone."
|
||||
confirmText="Delete"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
39
components/badge.tsx
Normal file
39
components/badge.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success:
|
||||
"border-transparent bg-green-100 text-green-800 hover:bg-green-200",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
|
||||
65
components/calendar.tsx
Normal file
65
components/calendar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-9 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: "text-center text-sm p-0 relative [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20",
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-9 w-9 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside: "text-muted-foreground opacity-50",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ ...props }) => <ChevronLeft className="h-4 w-4" />,
|
||||
IconRight: ({ ...props }) => <ChevronRight className="h-4 w-4" />,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
|
||||
36
components/ui/badge.tsx
Normal file
36
components/ui/badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
57
components/ui/button.tsx
Normal file
57
components/ui/button.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
76
components/ui/calendar.tsx
Normal file
76
components/ui/calendar.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { DayPicker } from "react-day-picker"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
|
||||
export type CalendarProps = React.ComponentProps<typeof DayPicker>
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
classNames,
|
||||
showOutsideDays = true,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
return (
|
||||
<DayPicker
|
||||
showOutsideDays={showOutsideDays}
|
||||
className={cn("p-3", className)}
|
||||
classNames={{
|
||||
months: "flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0",
|
||||
month: "space-y-4",
|
||||
caption: "flex justify-center pt-1 relative items-center",
|
||||
caption_label: "text-sm font-medium",
|
||||
nav: "space-x-1 flex items-center",
|
||||
nav_button: cn(
|
||||
buttonVariants({ variant: "outline" }),
|
||||
"h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100"
|
||||
),
|
||||
nav_button_previous: "absolute left-1",
|
||||
nav_button_next: "absolute right-1",
|
||||
table: "w-full border-collapse space-y-1",
|
||||
head_row: "flex",
|
||||
head_cell:
|
||||
"text-muted-foreground rounded-md w-8 font-normal text-[0.8rem]",
|
||||
row: "flex w-full mt-2",
|
||||
cell: cn(
|
||||
"relative p-0 text-center text-sm focus-within:relative focus-within:z-20 [&:has([aria-selected])]:bg-accent [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected].day-range-end)]:rounded-r-md",
|
||||
props.mode === "range"
|
||||
? "[&:has(>.day-range-end)]:rounded-r-md [&:has(>.day-range-start)]:rounded-l-md first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md"
|
||||
: "[&:has([aria-selected])]:rounded-md"
|
||||
),
|
||||
day: cn(
|
||||
buttonVariants({ variant: "ghost" }),
|
||||
"h-8 w-8 p-0 font-normal aria-selected:opacity-100"
|
||||
),
|
||||
day_range_start: "day-range-start",
|
||||
day_range_end: "day-range-end",
|
||||
day_selected:
|
||||
"bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground focus:bg-primary focus:text-primary-foreground",
|
||||
day_today: "bg-accent text-accent-foreground",
|
||||
day_outside:
|
||||
"day-outside text-muted-foreground aria-selected:bg-accent/50 aria-selected:text-muted-foreground",
|
||||
day_disabled: "text-muted-foreground opacity-50",
|
||||
day_range_middle:
|
||||
"aria-selected:bg-accent aria-selected:text-accent-foreground",
|
||||
day_hidden: "invisible",
|
||||
...classNames,
|
||||
}}
|
||||
components={{
|
||||
IconLeft: ({ className, ...props }) => (
|
||||
<ChevronLeft className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
IconRight: ({ className, ...props }) => (
|
||||
<ChevronRight className={cn("h-4 w-4", className)} {...props} />
|
||||
),
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Calendar.displayName = "Calendar"
|
||||
|
||||
export { Calendar }
|
||||
76
components/ui/card.tsx
Normal file
76
components/ui/card.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
365
components/ui/chart.tsx
Normal file
365
components/ui/chart.tsx
Normal file
@@ -0,0 +1,365 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RechartsPrimitive from "recharts"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// Format: { THEME_NAME: CSS_SELECTOR }
|
||||
const THEMES = { light: "", dark: ".dark" } as const
|
||||
|
||||
export type ChartConfig = {
|
||||
[k in string]: {
|
||||
label?: React.ReactNode
|
||||
icon?: React.ComponentType
|
||||
} & (
|
||||
| { color?: string; theme?: never }
|
||||
| { color?: never; theme: Record<keyof typeof THEMES, string> }
|
||||
)
|
||||
}
|
||||
|
||||
type ChartContextProps = {
|
||||
config: ChartConfig
|
||||
}
|
||||
|
||||
const ChartContext = React.createContext<ChartContextProps | null>(null)
|
||||
|
||||
function useChart() {
|
||||
const context = React.useContext(ChartContext)
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useChart must be used within a <ChartContainer />")
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
const ChartContainer = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & {
|
||||
config: ChartConfig
|
||||
children: React.ComponentProps<
|
||||
typeof RechartsPrimitive.ResponsiveContainer
|
||||
>["children"]
|
||||
}
|
||||
>(({ id, className, children, config, ...props }, ref) => {
|
||||
const uniqueId = React.useId()
|
||||
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
|
||||
|
||||
return (
|
||||
<ChartContext.Provider value={{ config }}>
|
||||
<div
|
||||
data-chart={chartId}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChartStyle id={chartId} config={config} />
|
||||
<RechartsPrimitive.ResponsiveContainer>
|
||||
{children}
|
||||
</RechartsPrimitive.ResponsiveContainer>
|
||||
</div>
|
||||
</ChartContext.Provider>
|
||||
)
|
||||
})
|
||||
ChartContainer.displayName = "Chart"
|
||||
|
||||
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
|
||||
const colorConfig = Object.entries(config).filter(
|
||||
([, config]) => config.theme || config.color
|
||||
)
|
||||
|
||||
if (!colorConfig.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: Object.entries(THEMES)
|
||||
.map(
|
||||
([theme, prefix]) => `
|
||||
${prefix} [data-chart=${id}] {
|
||||
${colorConfig
|
||||
.map(([key, itemConfig]) => {
|
||||
const color =
|
||||
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
|
||||
itemConfig.color
|
||||
return color ? ` --color-${key}: ${color};` : null
|
||||
})
|
||||
.join("\n")}
|
||||
}
|
||||
`
|
||||
)
|
||||
.join("\n"),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const ChartTooltip = RechartsPrimitive.Tooltip
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean
|
||||
hideIndicator?: boolean
|
||||
indicator?: "line" | "dot" | "dashed"
|
||||
nameKey?: string
|
||||
labelKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
active,
|
||||
payload,
|
||||
className,
|
||||
indicator = "dot",
|
||||
hideLabel = false,
|
||||
hideIndicator = false,
|
||||
label,
|
||||
labelFormatter,
|
||||
labelClassName,
|
||||
formatter,
|
||||
color,
|
||||
nameKey,
|
||||
labelKey,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
const tooltipLabel = React.useMemo(() => {
|
||||
if (hideLabel || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const [item] = payload
|
||||
const key = `${labelKey || item.dataKey || item.name || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const value =
|
||||
!labelKey && typeof label === "string"
|
||||
? config[label as keyof typeof config]?.label || label
|
||||
: itemConfig?.label
|
||||
|
||||
if (labelFormatter) {
|
||||
return (
|
||||
<div className={cn("font-medium", labelClassName)}>
|
||||
{labelFormatter(value, payload)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <div className={cn("font-medium", labelClassName)}>{value}</div>
|
||||
}, [
|
||||
label,
|
||||
labelFormatter,
|
||||
payload,
|
||||
hideLabel,
|
||||
labelClassName,
|
||||
config,
|
||||
labelKey,
|
||||
])
|
||||
|
||||
if (!active || !payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
const nestLabel = payload.length === 1 && indicator !== "dot"
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
const indicatorColor = color || item.payload.fill || item.color
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
|
||||
indicator === "dot" && "items-center"
|
||||
)}
|
||||
>
|
||||
{formatter && item?.value !== undefined && item.name ? (
|
||||
formatter(item.value, item.name, item, index, item.payload)
|
||||
) : (
|
||||
<>
|
||||
{itemConfig?.icon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
!hideIndicator && (
|
||||
<div
|
||||
className={cn(
|
||||
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
|
||||
{
|
||||
"h-2.5 w-2.5": indicator === "dot",
|
||||
"w-1": indicator === "line",
|
||||
"w-0 border-[1.5px] border-dashed bg-transparent":
|
||||
indicator === "dashed",
|
||||
"my-0.5": nestLabel && indicator === "dashed",
|
||||
}
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--color-bg": indicatorColor,
|
||||
"--color-border": indicatorColor,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-1 justify-between leading-none",
|
||||
nestLabel ? "items-end" : "items-center"
|
||||
)}
|
||||
>
|
||||
<div className="grid gap-1.5">
|
||||
{nestLabel ? tooltipLabel : null}
|
||||
<span className="text-muted-foreground">
|
||||
{itemConfig?.label || item.name}
|
||||
</span>
|
||||
</div>
|
||||
{item.value && (
|
||||
<span className="font-mono font-medium tabular-nums text-foreground">
|
||||
{item.value.toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartTooltipContent.displayName = "ChartTooltip"
|
||||
|
||||
const ChartLegend = RechartsPrimitive.Legend
|
||||
|
||||
const ChartLegendContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> &
|
||||
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
|
||||
hideIcon?: boolean
|
||||
nameKey?: string
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
|
||||
ref
|
||||
) => {
|
||||
const { config } = useChart()
|
||||
|
||||
if (!payload?.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-4",
|
||||
verticalAlign === "top" ? "pb-3" : "pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{payload.map((item) => {
|
||||
const key = `${nameKey || item.dataKey || "value"}`
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.value}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
{itemConfig?.icon && !hideIcon ? (
|
||||
<itemConfig.icon />
|
||||
) : (
|
||||
<div
|
||||
className="h-2 w-2 shrink-0 rounded-[2px]"
|
||||
style={{
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{itemConfig?.label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
ChartLegendContent.displayName = "ChartLegend"
|
||||
|
||||
// Helper to extract item config from a payload.
|
||||
function getPayloadConfigFromPayload(
|
||||
config: ChartConfig,
|
||||
payload: unknown,
|
||||
key: string
|
||||
) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const payloadPayload =
|
||||
"payload" in payload &&
|
||||
typeof payload.payload === "object" &&
|
||||
payload.payload !== null
|
||||
? payload.payload
|
||||
: undefined
|
||||
|
||||
let configLabelKey: string = key
|
||||
|
||||
if (
|
||||
key in payload &&
|
||||
typeof payload[key as keyof typeof payload] === "string"
|
||||
) {
|
||||
configLabelKey = payload[key as keyof typeof payload] as string
|
||||
} else if (
|
||||
payloadPayload &&
|
||||
key in payloadPayload &&
|
||||
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
|
||||
) {
|
||||
configLabelKey = payloadPayload[
|
||||
key as keyof typeof payloadPayload
|
||||
] as string
|
||||
}
|
||||
|
||||
return configLabelKey in config
|
||||
? config[configLabelKey]
|
||||
: config[key as keyof typeof config]
|
||||
}
|
||||
|
||||
export {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
ChartLegend,
|
||||
ChartLegendContent,
|
||||
ChartStyle,
|
||||
}
|
||||
122
components/ui/dialog.tsx
Normal file
122
components/ui/dialog.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col space-y-1.5 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
22
components/ui/input.tsx
Normal file
22
components/ui/input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
26
components/ui/label.tsx
Normal file
26
components/ui/label.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<LabelPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = LabelPrimitive.Root.displayName
|
||||
|
||||
export { Label }
|
||||
28
components/ui/progress.tsx
Normal file
28
components/ui/progress.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof ProgressPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<ProgressPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
))
|
||||
Progress.displayName = ProgressPrimitive.Root.displayName
|
||||
|
||||
export { Progress }
|
||||
159
components/ui/select.tsx
Normal file
159
components/ui/select.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SelectPrimitive from "@radix-ui/react-select"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectScrollUpButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollUpButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
))
|
||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||
|
||||
const SelectScrollDownButton = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.ScrollDownButton
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default items-center justify-center py-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
))
|
||||
SelectScrollDownButton.displayName =
|
||||
SelectPrimitive.ScrollDownButton.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1.5 text-sm font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
22
components/ui/textarea.tsx
Normal file
22
components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
129
components/ui/toast.tsx
Normal file
129
components/ui/toast.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ToastPrimitives from "@radix-ui/react-toast"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ToastProvider = ToastPrimitives.Provider
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Viewport>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Viewport
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
|
||||
|
||||
const toastVariants = cva(
|
||||
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "border bg-background text-foreground",
|
||||
destructive:
|
||||
"destructive group border-destructive bg-destructive text-destructive-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
|
||||
VariantProps<typeof toastVariants>
|
||||
>(({ className, variant, ...props }, ref) => {
|
||||
return (
|
||||
<ToastPrimitives.Root
|
||||
ref={ref}
|
||||
className={cn(toastVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Toast.displayName = ToastPrimitives.Root.displayName
|
||||
|
||||
const ToastAction = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Action>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Action
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastAction.displayName = ToastPrimitives.Action.displayName
|
||||
|
||||
const ToastClose = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Close
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
|
||||
className
|
||||
)}
|
||||
toast-close=""
|
||||
{...props}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</ToastPrimitives.Close>
|
||||
))
|
||||
ToastClose.displayName = ToastPrimitives.Close.displayName
|
||||
|
||||
const ToastTitle = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Title
|
||||
ref={ref}
|
||||
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastTitle.displayName = ToastPrimitives.Title.displayName
|
||||
|
||||
const ToastDescription = React.forwardRef<
|
||||
React.ElementRef<typeof ToastPrimitives.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ToastPrimitives.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm opacity-90", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ToastDescription.displayName = ToastPrimitives.Description.displayName
|
||||
|
||||
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
|
||||
|
||||
type ToastActionElement = React.ReactElement<typeof ToastAction>
|
||||
|
||||
export {
|
||||
type ToastProps,
|
||||
type ToastActionElement,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
Toast,
|
||||
ToastTitle,
|
||||
ToastDescription,
|
||||
ToastClose,
|
||||
ToastAction,
|
||||
}
|
||||
35
components/ui/toaster.tsx
Normal file
35
components/ui/toaster.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
"use client"
|
||||
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast()
|
||||
|
||||
return (
|
||||
<ToastProvider>
|
||||
{toasts.map(function ({ id, title, description, action, ...props }) {
|
||||
return (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && (
|
||||
<ToastDescription>{description}</ToastDescription>
|
||||
)}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
)
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
29
eslint.config.mjs
Normal file
29
eslint.config.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import unusedImports from "eslint-plugin-unused-imports";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const customEslintRules = {
|
||||
plugins: {
|
||||
"unused-imports": unusedImports,
|
||||
},
|
||||
rules: {
|
||||
"unused-imports/no-unused-imports": "warn",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"react/no-unescaped-entities": 0
|
||||
},
|
||||
}
|
||||
|
||||
const eslintConfig = [
|
||||
...compat.extends("next/core-web-vitals", "next/typescript"),
|
||||
customEslintRules,
|
||||
];
|
||||
|
||||
export default eslintConfig;
|
||||
194
hooks/use-toast.ts
Normal file
194
hooks/use-toast.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
"use client"
|
||||
|
||||
// Inspired by react-hot-toast library
|
||||
import * as React from "react"
|
||||
|
||||
import type {
|
||||
ToastActionElement,
|
||||
ToastProps,
|
||||
} from "@/components/ui/toast"
|
||||
|
||||
const TOAST_LIMIT = 1
|
||||
const TOAST_REMOVE_DELAY = 1000000
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
action?: ToastActionElement
|
||||
}
|
||||
|
||||
const actionTypes = {
|
||||
ADD_TOAST: "ADD_TOAST",
|
||||
UPDATE_TOAST: "UPDATE_TOAST",
|
||||
DISMISS_TOAST: "DISMISS_TOAST",
|
||||
REMOVE_TOAST: "REMOVE_TOAST",
|
||||
} as const
|
||||
|
||||
let count = 0
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER
|
||||
return count.toString()
|
||||
}
|
||||
|
||||
type ActionType = typeof actionTypes
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"]
|
||||
toast: ToasterToast
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"]
|
||||
toast: Partial<ToasterToast>
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"]
|
||||
toastId?: ToasterToast["id"]
|
||||
}
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[]
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId)
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
})
|
||||
}, TOAST_REMOVE_DELAY)
|
||||
|
||||
toastTimeouts.set(toastId, timeout)
|
||||
}
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
}
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t
|
||||
),
|
||||
}
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId)
|
||||
} else {
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id)
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t
|
||||
),
|
||||
}
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const listeners: Array<(state: State) => void> = []
|
||||
|
||||
let memoryState: State = { toasts: [] }
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action)
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState)
|
||||
})
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId()
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
})
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss()
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
}
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [state])
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
}
|
||||
}
|
||||
|
||||
export { useToast, toast }
|
||||
67
hooks/useCoins.tsx
Normal file
67
hooks/useCoins.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadCoinsData, addCoins, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { CoinTransaction, TransactionType } from '@/lib/types'
|
||||
|
||||
export function useCoins() {
|
||||
const [balance, setBalance] = useState(0)
|
||||
const [transactions, setTransactions] = useState<CoinTransaction[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchCoins()
|
||||
}, [])
|
||||
|
||||
const fetchCoins = async () => {
|
||||
const data = await loadCoinsData()
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions || [])
|
||||
}
|
||||
|
||||
const addAmount = async (
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
) => {
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({
|
||||
title: "Invalid amount",
|
||||
description: "Please enter a valid positive number"
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await addCoins(amount, description, type, relatedItemId)
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions)
|
||||
return data
|
||||
}
|
||||
|
||||
const removeAmount = async (
|
||||
amount: number,
|
||||
description: string,
|
||||
type: TransactionType = 'MANUAL_ADJUSTMENT',
|
||||
relatedItemId?: string
|
||||
) => {
|
||||
if (isNaN(amount) || amount <= 0) {
|
||||
toast({
|
||||
title: "Invalid amount",
|
||||
description: "Please enter a valid positive number"
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await removeCoins(amount, description, type, relatedItemId)
|
||||
setBalance(data.balance)
|
||||
setTransactions(data.transactions)
|
||||
return data
|
||||
}
|
||||
|
||||
return {
|
||||
balance,
|
||||
transactions,
|
||||
addAmount,
|
||||
removeAmount,
|
||||
fetchCoins
|
||||
}
|
||||
}
|
||||
104
hooks/useHabits.tsx
Normal file
104
hooks/useHabits.tsx
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadHabitsData, saveHabitsData, addCoins, removeCoins } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { Undo2 } from 'lucide-react'
|
||||
import { Habit } from '@/lib/types'
|
||||
|
||||
export function useHabits() {
|
||||
const [habits, setHabits] = useState<Habit[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetchHabits()
|
||||
}, [])
|
||||
|
||||
const fetchHabits = async () => {
|
||||
const data = await loadHabitsData()
|
||||
setHabits(data.habits)
|
||||
}
|
||||
|
||||
const addHabit = async (habit: Omit<Habit, 'id'>) => {
|
||||
const newHabit = { ...habit, id: Date.now().toString() }
|
||||
const newHabits = [...habits, newHabit]
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const editHabit = async (updatedHabit: Habit) => {
|
||||
const newHabits = habits.map(habit =>
|
||||
habit.id === updatedHabit.id ? updatedHabit : habit
|
||||
)
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const deleteHabit = async (id: string) => {
|
||||
const newHabits = habits.filter(habit => habit.id !== id)
|
||||
setHabits(newHabits)
|
||||
await saveHabitsData({ habits: newHabits })
|
||||
}
|
||||
|
||||
const completeHabit = async (habit: Habit) => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
if (!habit.completions.includes(today)) {
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: [...habit.completions, today]
|
||||
}
|
||||
const updatedHabits = habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
const coinsData = await addCoins(habit.coinReward, `Completed habit: ${habit.name}`, 'HABIT_COMPLETION', habit.id)
|
||||
|
||||
setHabits(updatedHabits)
|
||||
|
||||
toast({
|
||||
title: "Habit completed!",
|
||||
description: `You earned ${habit.coinReward} coins.`,
|
||||
action: <ToastAction altText="Undo" className="gap-2" onClick={() => undoComplete(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction>
|
||||
})
|
||||
|
||||
return coinsData.balance
|
||||
} else {
|
||||
toast({
|
||||
title: "Habit already completed",
|
||||
description: "You've already completed this habit today.",
|
||||
variant: "destructive",
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const undoComplete = async (habit: Habit) => {
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const updatedHabit = {
|
||||
...habit,
|
||||
completions: habit.completions.filter(date => date !== today)
|
||||
}
|
||||
const updatedHabits = habits.map(h =>
|
||||
h.id === habit.id ? updatedHabit : h
|
||||
)
|
||||
await saveHabitsData({ habits: updatedHabits })
|
||||
const coinsData = await removeCoins(habit.coinReward, `Undid habit completion: ${habit.name}`, 'HABIT_UNDO', habit.id)
|
||||
|
||||
setHabits(updatedHabits)
|
||||
|
||||
toast({
|
||||
title: "Completion undone",
|
||||
description: `${habit.coinReward} coins have been deducted.`,
|
||||
action: <ToastAction altText="Redo" onClick={() => completeHabit(habit)}><Undo2 className="h-4 w-4" />Undo</ToastAction>
|
||||
})
|
||||
|
||||
return coinsData.balance
|
||||
}
|
||||
|
||||
return {
|
||||
habits,
|
||||
addHabit,
|
||||
editHabit,
|
||||
deleteHabit,
|
||||
completeHabit,
|
||||
undoComplete
|
||||
}
|
||||
}
|
||||
84
hooks/useWishlist.tsx
Normal file
84
hooks/useWishlist.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { loadWishlistItems, saveWishlistItems } from '@/app/actions/data'
|
||||
import { toast } from '@/hooks/use-toast'
|
||||
import { WishlistItemType } from '@/lib/types'
|
||||
import { useCoins } from '@/hooks/useCoins'
|
||||
import { celebrations } from '@/utils/celebrations'
|
||||
|
||||
export function useWishlist() {
|
||||
const [wishlistItems, setWishlistItems] = useState<WishlistItemType[]>([])
|
||||
const { balance, removeAmount } = useCoins()
|
||||
|
||||
useEffect(() => {
|
||||
fetchWishlistItems()
|
||||
}, [])
|
||||
|
||||
const fetchWishlistItems = async () => {
|
||||
const items = await loadWishlistItems()
|
||||
setWishlistItems(items)
|
||||
}
|
||||
|
||||
const addWishlistItem = async (item: Omit<WishlistItemType, 'id'>) => {
|
||||
const newItem = { ...item, id: Date.now().toString() }
|
||||
const newItems = [...wishlistItems, newItem]
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const editWishlistItem = async (updatedItem: WishlistItemType) => {
|
||||
const newItems = wishlistItems.map(item =>
|
||||
item.id === updatedItem.id ? updatedItem : item
|
||||
)
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const deleteWishlistItem = async (id: string) => {
|
||||
const newItems = wishlistItems.filter(item => item.id !== id)
|
||||
setWishlistItems(newItems)
|
||||
await saveWishlistItems(newItems)
|
||||
}
|
||||
|
||||
const redeemWishlistItem = async (item: WishlistItemType) => {
|
||||
if (balance >= item.coinCost) {
|
||||
await removeAmount(
|
||||
item.coinCost,
|
||||
`Redeemed reward: ${item.name}`,
|
||||
'WISH_REDEMPTION',
|
||||
item.id
|
||||
)
|
||||
|
||||
// Randomly choose a celebration effect
|
||||
const celebrationEffects = [
|
||||
celebrations.basic,
|
||||
celebrations.fireworks,
|
||||
celebrations.shower
|
||||
]
|
||||
const randomEffect = celebrationEffects[Math.floor(Math.random() * celebrationEffects.length)]
|
||||
randomEffect()
|
||||
|
||||
toast({
|
||||
title: "🎉 Reward Redeemed!",
|
||||
description: `You've redeemed "${item.name}" for ${item.coinCost} coins.`,
|
||||
})
|
||||
|
||||
return true
|
||||
} else {
|
||||
toast({
|
||||
title: "Not enough coins",
|
||||
description: `You need ${item.coinCost - balance} more coins to redeem this reward.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
wishlistItems,
|
||||
addWishlistItem,
|
||||
editWishlistItem,
|
||||
deleteWishlistItem,
|
||||
redeemWishlistItem,
|
||||
canRedeem: (cost: number) => balance >= cost
|
||||
}
|
||||
}
|
||||
35
lib/types.ts
Normal file
35
lib/types.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export type Habit = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
frequency: 'daily' | 'weekly' | 'monthly'
|
||||
coinReward: number
|
||||
completions: string[] // Array of ISO date strings
|
||||
}
|
||||
|
||||
export type WishlistItemType = {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
coinCost: number
|
||||
}
|
||||
|
||||
export type TransactionType = 'HABIT_COMPLETION' | 'HABIT_UNDO' | 'WISH_REDEMPTION' | 'MANUAL_ADJUSTMENT';
|
||||
|
||||
export interface CoinTransaction {
|
||||
id: string;
|
||||
amount: number;
|
||||
type: TransactionType;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
relatedItemId?: string;
|
||||
}
|
||||
|
||||
export interface HabitsData {
|
||||
habits: Habit[];
|
||||
}
|
||||
|
||||
export interface CoinsData {
|
||||
balance: number;
|
||||
transactions: CoinTransaction[];
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
8
next.config.ts
Normal file
8
next.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
6526
package-lock.json
generated
Normal file
6526
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
50
package.json
Normal file
50
package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "habittrove",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"docker-build": "docker build -t habittrove .",
|
||||
"docker-run": "docker run -p 3000:3000 -v $(pwd)/data:/app/data habittrove",
|
||||
"docker-push": "docker tag habittrove dohsimpson/habittrove && docker push dohsimpson/habittrove"
|
||||
},
|
||||
"dependencies": {
|
||||
"@next/font": "^14.2.15",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-label": "^2.1.1",
|
||||
"@radix-ui/react-progress": "^1.1.1",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@uiw/react-heat-map": "^2.3.2",
|
||||
"canvas-confetti": "^1.9.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.469.0",
|
||||
"next": "15.1.3",
|
||||
"react": "^19.0.0",
|
||||
"react-confetti": "^6.2.2",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@types/node": "^20.17.10",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.3",
|
||||
"eslint-plugin-unused-imports": "^4.1.4",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
8
postcss.config.mjs
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('postcss-load-config').Config} */
|
||||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
public/globe.svg
Normal file
1
public/globe.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
public/next.svg
Normal file
1
public/next.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
public/vercel.svg
Normal file
1
public/vercel.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
public/window.svg
Normal file
1
public/window.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
73
tailwind.config.ts
Normal file
73
tailwind.config.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
export default {
|
||||
darkMode: ["class"],
|
||||
content: [
|
||||
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
keyframes: {
|
||||
celebrate: {
|
||||
'0%, 100%': { transform: 'scale(1)' },
|
||||
'25%': { transform: 'scale(1.05) rotate(-1deg)' },
|
||||
'50%': { transform: 'scale(1.1) rotate(1deg)' },
|
||||
'75%': { transform: 'scale(1.05) rotate(-1deg)' }
|
||||
}
|
||||
},
|
||||
animation: {
|
||||
celebrate: 'celebrate 1s ease-in-out'
|
||||
},
|
||||
colors: {
|
||||
background: 'hsl(var(--background))',
|
||||
foreground: 'hsl(var(--foreground))',
|
||||
card: {
|
||||
DEFAULT: 'hsl(var(--card))',
|
||||
foreground: 'hsl(var(--card-foreground))'
|
||||
},
|
||||
popover: {
|
||||
DEFAULT: 'hsl(var(--popover))',
|
||||
foreground: 'hsl(var(--popover-foreground))'
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: 'hsl(var(--primary))',
|
||||
foreground: 'hsl(var(--primary-foreground))'
|
||||
},
|
||||
secondary: {
|
||||
DEFAULT: 'hsl(var(--secondary))',
|
||||
foreground: 'hsl(var(--secondary-foreground))'
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: 'hsl(var(--muted))',
|
||||
foreground: 'hsl(var(--muted-foreground))'
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: 'hsl(var(--accent))',
|
||||
foreground: 'hsl(var(--accent-foreground))'
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: 'hsl(var(--destructive))',
|
||||
foreground: 'hsl(var(--destructive-foreground))'
|
||||
},
|
||||
border: 'hsl(var(--border))',
|
||||
input: 'hsl(var(--input))',
|
||||
ring: 'hsl(var(--ring))',
|
||||
chart: {
|
||||
'1': 'hsl(var(--chart-1))',
|
||||
'2': 'hsl(var(--chart-2))',
|
||||
'3': 'hsl(var(--chart-3))',
|
||||
'4': 'hsl(var(--chart-4))',
|
||||
'5': 'hsl(var(--chart-5))'
|
||||
}
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
md: 'calc(var(--radius) - 2px)',
|
||||
sm: 'calc(var(--radius) - 4px)'
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwindcss-animate")],
|
||||
} satisfies Config;
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"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"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
58
utils/celebrations.ts
Normal file
58
utils/celebrations.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import confetti from 'canvas-confetti'
|
||||
|
||||
export const celebrations = {
|
||||
basic: () => {
|
||||
confetti({
|
||||
particleCount: 200,
|
||||
spread: 70,
|
||||
origin: { y: 0.6 }
|
||||
})
|
||||
},
|
||||
|
||||
fireworks: () => {
|
||||
const duration = 3000
|
||||
const animationEnd = Date.now() + duration
|
||||
const defaults = { startVelocity: 30, spread: 360, ticks: 60, zIndex: 0 }
|
||||
|
||||
const interval: any = setInterval(function() {
|
||||
const timeLeft = animationEnd - Date.now()
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
return clearInterval(interval)
|
||||
}
|
||||
|
||||
const particleCount = 50 * (timeLeft / duration)
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount,
|
||||
origin: { x: Math.random(), y: Math.random() - 0.2 }
|
||||
})
|
||||
}, 250)
|
||||
},
|
||||
|
||||
shower: () => {
|
||||
const end = Date.now() + 2000
|
||||
|
||||
const colors = ['#ff0000', '#00ff00', '#0000ff']
|
||||
;(function frame() {
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 60,
|
||||
spread: 55,
|
||||
origin: { x: 0 },
|
||||
colors: colors
|
||||
})
|
||||
confetti({
|
||||
particleCount: 2,
|
||||
angle: 120,
|
||||
spread: 55,
|
||||
origin: { x: 1 },
|
||||
colors: colors
|
||||
})
|
||||
|
||||
if (Date.now() < end) {
|
||||
requestAnimationFrame(frame)
|
||||
}
|
||||
}())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user