commit ebd4775d5effa8c77741f2cfda07c3296d8a1806 Author: RainbowYoshi Date: Sat Jan 31 21:51:06 2026 +0000 TΓ©lΓ©verser les fichiers vers "/" diff --git a/README.md b/README.md new file mode 100644 index 0000000..65bb614 --- /dev/null +++ b/README.md @@ -0,0 +1,62 @@ +# PortaGit + +PortaGit is a lightweight, self-hosted Git portfolio and repository viewer designed to showcase your work with a beautiful, modern interface. Built with Go, it offers a personal GitHub-like experience that you can host anywhere. + +![PortaGit Preview](https://github.com/user-attachments/assets/placeholder) + +## Features + +- **πŸ‘€ Repository Viewer**: Browse your files, commit history, and branches with a clean UI. +- **πŸ“Š Contribution Graph**: Visualize your coding activity with a GitHub-style contribution calendar. +- **🎨 Modern Design**: Sleek dark mode interface with responsive layout and smooth transitions. +- **πŸ› οΈ Easy Setup**: Built-in setup wizard to configure your profile, bio, and social links. +- **πŸ“ Markdown Support**: Renders `README.md` files automatically. +- **πŸ“ File Management**: Create files, folders, and upload assets directly through the web interface. +- **βš™οΈ Customizable**: personalize your profile, accent colors, and more. + +## Installation + +### Prerequisites + +- [Go](https://go.dev/dl/) 1.19 or higher +- Git installed on your system + +### Build from Source + +1. Clone the repository: + ```bash + git clone https://github.com/yourusername/PortaGit.git + cd PortaGit + ``` + +2. Build the application: + ```bash + go build -o portagit + ``` + +3. Run the application: + ```bash + ./portagit + ``` + +4. Open your browser and navigate to `http://localhost:8080`. + +## Configuration + +On the first run, PortaGit will launch a **Setup Wizard** allowing you to: +- Set your display name and bio. +- Import data from your GitHub profile. +- Configure themes and appearance. + +The configuration is stored in a `portagit.json` file in the root directory. + +## Project Structure + +- `main.go`: Application entry point and HTTP server. +- `git_utils.go`: Helper functions for interacting with Git repositories. +- `web/`: Contains HTML templates and static assets (CSS, JS). +- `repositories/`: Directory where your git repositories are stored (created automatically). + +## Contributing + +Contributions are welcome! Feel free to open issues or submit pull requests. \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..86b300c --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module portagit + +go 1.25.3 + +require github.com/go-git/go-git/v5 v5.16.4 + +require ( + dario.cat/mergo v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/crypto v0.37.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/sys v0.32.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..97d8226 --- /dev/null +++ b/go.sum @@ -0,0 +1,109 @@ +dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= +dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= +golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e61e43c --- /dev/null +++ b/main.go @@ -0,0 +1,982 @@ +package main + +import ( + "archive/zip" + "bytes" + "embed" + "encoding/json" + "fmt" + "html/template" + "io" + "io/fs" + "log" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" +) + +//go:embed web +var assets embed.FS + +// ConfigFileName defines the name of the configuration file expected in the root directory. +const configFileName = "portagit.json" + +func main() { + cwd, err := os.Getwd() + if err != nil { + log.Fatal(err) + } + + // Ensure that the repositories are organized according to the expected directory structure. + migrateRepositories(cwd) + + os.MkdirAll(filepath.Join(cwd, "uploads"), 0755) + + webFS, err := fs.Sub(assets, "web") + if err != nil { + log.Fatal(err) + } + + // Initialize the template function map with helper functions for HTML rendering. + funcMap := template.FuncMap{ + "safe": func(s string) template.HTML { return template.HTML(s) }, + "iterate": func(count int) []int { + var i []int + for j := 0; j < count; j++ { + i = append(i, j) + } + return i + }, + "humanize": humanize, + "dict": func(values ...interface{}) (map[string]interface{}, error) { + if len(values)%2 != 0 { + return nil, fmt.Errorf("invalid dict call") + } + dict := make(map[string]interface{}, len(values)/2) + for i := 0; i < len(values); i += 2 { + key, ok := values[i].(string) + if !ok { + return nil, fmt.Errorf("dict keys must be strings") + } + dict[key] = values[i+1] + } + return dict, nil + }, + "languageColor": func(lang string) string { + colors := map[string]string{ + // Map of programming languages to their respective hex color codes, inspired by GitHub's color palette. + "Go": "#00ADD8", + "JavaScript": "#F1E05A", + "TypeScript": "#3178C6", + "Python": "#3572A5", + "HTML": "#E34C26", + "CSS": "#563D7C", + "Java": "#B07219", + "C++": "#F34B7D", + "C": "#555555", + "PHP": "#4F5D95", + "Ruby": "#701516", + "Rust": "#DEA584", + "Shell": "#89E051", + "C#": "#178600", + "Swift": "#F05138", + "Kotlin": "#A97BFF", + "Dart": "#00B4AB", + "Lua": "#000080", + } + if c, ok := colors[lang]; ok { + return c + } + return "#8b949e" + }, + } + + tmpl, err := template.New("base").Funcs(funcMap).ParseFS(webFS, "templates/*.html") + if err != nil { + log.Fatal("Error parsing templates:", err) + } + + // Register the handler for the initial setup wizard. + http.HandleFunc("/setup", func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + tmpl.ExecuteTemplate(w, "setup.html", nil) + return + } + + if r.Method == "POST" { + err := r.ParseMultipartForm(10 << 20) + if err != nil && err != http.ErrNotMultipart { + http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError) + return + } + if err == http.ErrNotMultipart { + if err := r.ParseForm(); err != nil { + http.Error(w, "Error parsing standard form", http.StatusInternalServerError) + return + } + } + username := r.FormValue("username") + bio := r.FormValue("bio") + githubUser := r.FormValue("github_username") + avatarPath := "" + + if githubUser != "" { + isStream := r.URL.Query().Get("stream") == "true" + var flusher http.Flusher + if isStream { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + if f, ok := w.(http.Flusher); ok { + flusher = f + } + } + + // sendEvent is a helper function to stream Server-Sent Events (SSE) to the client. + sendEvent := func(current, total int, msg string) { + if isStream && flusher != nil { + payload := map[string]interface{}{ + "current": current, + "total": total, + "message": msg, + } + jsonPayload, _ := json.Marshal(payload) + fmt.Fprintf(w, "data: %s\n\n", jsonPayload) + flusher.Flush() + } + } + + sendEvent(0, 0, "Fetching Profile...") + + resp, err := http.Get("https://api.github.com/users/" + githubUser) + if err == nil && resp.StatusCode == 200 { + defer resp.Body.Close() + var ghUser GithubUser + if err := json.NewDecoder(resp.Body).Decode(&ghUser); err == nil { + if username == "" { + username = ghUser.Name + if username == "" { + username = ghUser.Login + } + } + if bio == "" { + bio = ghUser.Bio + } + if ghUser.AvatarUrl != "" { + sendEvent(0, 0, "Downloading Avatar...") + resp, err := http.Get(ghUser.AvatarUrl) + if err == nil && resp.StatusCode == 200 { + defer resp.Body.Close() + filename := fmt.Sprintf("gh_avatar_%d.png", time.Now().Unix()) + destPath := filepath.Join(cwd, "uploads", filename) + out, err := os.Create(destPath) + if err == nil { + defer out.Close() + io.Copy(out, resp.Body) + avatarPath = "/uploads/" + filename + } + } + } + } + } + + sendEvent(0, 0, "Fetching Repositories...") + + respRepos, err := http.Get("https://api.github.com/users/" + githubUser + "/repos") + if err == nil && respRepos.StatusCode == 200 { + defer respRepos.Body.Close() + var ghRepos []GithubRepo + if err := json.NewDecoder(respRepos.Body).Decode(&ghRepos); err == nil { + reposDir := filepath.Join(cwd, "repositories") + os.MkdirAll(reposDir, 0755) + + totalRepos := len(ghRepos) + for i, repo := range ghRepos { + sendEvent(i+1, totalRepos, fmt.Sprintf("Cloning %s...", repo.Name)) + + target := filepath.Join(reposDir, repo.Name) + if _, err := os.Stat(target); os.IsNotExist(err) { + exec.Command("git", "clone", repo.CloneUrl, target).Run() + } + + if repo.Description != "" { + descPath := filepath.Join(target, ".git", "description") + os.MkdirAll(filepath.Dir(descPath), 0755) + os.WriteFile(descPath, []byte(repo.Description), 0644) + } + } + } + } + + if isStream { + newConfig := Config{ + Username: username, + GithubUser: githubUser, + Bio: bio, + Avatar: avatarPath, + Socials: Socials{ + Github: "https://github.com/" + githubUser, + }, + CustomColors: CustomColors{ + BgColor: "#0d1117", + CardBg: "#161b22", + BorderColor: "#30363d", + AccentColor: "#58a6ff", + TextPrimary: "#c9d1d9", + TextSecondary: "#8b949e", + TextMuted: "#484f58", + ButtonText: "#ffffff", + }, + Repositories: []Repository{}, + } + + files, _ := json.MarshalIndent(newConfig, "", " ") + os.WriteFile(filepath.Join(cwd, configFileName), files, 0644) + + payload := map[string]interface{}{ + "redirect": "/", + } + jsonPayload, _ := json.Marshal(payload) + fmt.Fprintf(w, "data: %s\n\n", jsonPayload) + if flusher != nil { + flusher.Flush() + } + return + } + } + + file, handler, err := r.FormFile("avatar") + if err == nil { + defer file.Close() + ext := filepath.Ext(handler.Filename) + if ext == "" { + ext = ".png" + } + filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext) + destPath := filepath.Join(cwd, "uploads", filename) + dst, err := os.Create(destPath) + if err == nil { + defer dst.Close() + io.Copy(dst, file) + avatarPath = "/uploads/" + filename + } + } + + newConfig := Config{ + Username: username, + Bio: bio, + Avatar: avatarPath, + } + saveConfig(filepath.Join(cwd, configFileName), newConfig) + http.Redirect(w, r, "/", http.StatusSeeOther) + } + }) + + // Register the handler for user settings, including profile and appearance updates. + http.HandleFunc("/settings", func(w http.ResponseWriter, r *http.Request) { + currentConfig := loadConfig(cwd) + currentTab := r.URL.Query().Get("tab") + if currentTab == "" { + currentTab = "profile" + } + + if r.Method == "GET" { + data := SettingsData{ + Config: currentConfig, + CurrentTab: currentTab, + } + tmpl.ExecuteTemplate(w, "settings.html", data) + return + } + + if r.Method == "POST" { + err := r.ParseMultipartForm(10 << 20) + if err != nil && err != http.ErrNotMultipart { + http.Error(w, "Error parsing form: "+err.Error(), http.StatusInternalServerError) + return + } + if err == http.ErrNotMultipart { + if err := r.ParseForm(); err != nil { + http.Error(w, "Error parsing standard form", http.StatusInternalServerError) + return + } + } + + if r.FormValue("action") == "update_profile" { + currentConfig.Username = r.FormValue("username") + currentConfig.Bio = r.FormValue("bio") + currentConfig.Email = r.FormValue("email") + currentConfig.Socials.Website = r.FormValue("website") + currentConfig.Socials.Github = r.FormValue("github") + currentConfig.Socials.Linkedin = r.FormValue("linkedin") + + file, handler, err := r.FormFile("avatar") + if err == nil { + defer file.Close() + ext := filepath.Ext(handler.Filename) + if ext == "" { + ext = ".png" + } + filename := fmt.Sprintf("avatar_%d%s", time.Now().Unix(), ext) + destPath := filepath.Join(cwd, "uploads", filename) + dst, err := os.Create(destPath) + if err == nil { + defer dst.Close() + io.Copy(dst, file) + currentConfig.Avatar = "/uploads/" + filename + } + } + } else if r.FormValue("action") == "update_appearance" { + currentConfig.CustomColors.BgColor = r.FormValue("bg_color") + currentConfig.CustomColors.CardBg = r.FormValue("card_bg") + currentConfig.CustomColors.BorderColor = r.FormValue("border_color") + currentConfig.CustomColors.AccentColor = r.FormValue("accent_color") + currentConfig.CustomColors.TextPrimary = r.FormValue("text_primary") + currentConfig.CustomColors.TextSecondary = r.FormValue("text_secondary") + currentConfig.CustomColors.TextMuted = r.FormValue("text_muted") + currentConfig.CustomColors.ButtonText = r.FormValue("button_text") + } + + saveConfig(filepath.Join(cwd, configFileName), currentConfig) + + http.Redirect(w, r, "/settings?tab="+currentTab, http.StatusSeeOther) + } + }) + + // Register the handler for updating pinned repositories. + http.HandleFunc("/api/pins", func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := r.ParseForm(); err != nil { + http.Error(w, "Error parsing form", http.StatusInternalServerError) + return + } + + currentConfig := loadConfig(cwd) + currentConfig.PinnedRepos = r.Form["pinned_repos"] // "pinned_repos" will be a list of repo names + saveConfig(filepath.Join(cwd, configFileName), currentConfig) + + http.Redirect(w, r, "/", http.StatusSeeOther) + }) + + // Register the handler for creating new repositories. + http.HandleFunc("/new", func(w http.ResponseWriter, r *http.Request) { + currentConfig := loadConfig(cwd) + + if r.Method == "GET" { + data := PageData{ + Config: currentConfig, + } + tmpl.ExecuteTemplate(w, "new.html", data) + return + } + + if r.Method == "POST" { + repoName := r.FormValue("repo_name") + description := r.FormValue("description") + initReadme := r.FormValue("init_readme") == "on" + + if repoName == "" { + http.Error(w, "Repository name is required", http.StatusBadRequest) + return + } + + targetPath := filepath.Join(cwd, "repositories", repoName) + + if err := os.MkdirAll(targetPath, 0755); err != nil { + http.Error(w, "Failed to create directory: "+err.Error(), http.StatusInternalServerError) + return + } + + cmd := exec.Command("git", "init") + cmd.Dir = targetPath + if err := cmd.Run(); err != nil { + http.Error(w, "Failed to run git init: "+err.Error(), http.StatusInternalServerError) + return + } + + if initReadme { + readmePath := filepath.Join(targetPath, "README.md") + content := fmt.Sprintf("# %s\n\n%s", repoName, description) + if err := os.WriteFile(readmePath, []byte(content), 0644); err == nil { + exec.Command("git", "-C", targetPath, "add", "README.md").Run() + exec.Command("git", "-C", targetPath, "commit", "-m", "Initial commit").Run() + } + } + + http.Redirect(w, r, "/?tab=repositories", http.StatusSeeOther) + } + }) + + // Register the handler for browsing repository contents (files, branches, and commits). + http.HandleFunc("/repo", func(w http.ResponseWriter, r *http.Request) { + currentConfig := loadConfig(cwd) + repoName := r.URL.Query().Get("name") + subPath := r.URL.Query().Get("path") + viewType := r.URL.Query().Get("type") + + if repoName == "" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + repoRoot := filepath.Join(cwd, "repositories", repoName) + + targetPath := filepath.Join(repoRoot, subPath) + if !strings.HasPrefix(targetPath, repoRoot) { + http.Error(w, "Invalid path", http.StatusForbidden) + return + } + + if r.Method == "POST" { + action := r.FormValue("action") + switch action { + case "create_branch": + branchName := r.FormValue("branch_name") + if branchName != "" { + err := createBranch(repoRoot, branchName) + if err != nil { + log.Println("Error creating branch:", err) + } + } + http.Redirect(w, r, "/repo?name="+repoName, http.StatusFound) + return + case "switch_branch": + branch := r.FormValue("branch") + if branch != "" { + exec.Command("git", "-C", repoRoot, "checkout", branch).Run() + } + http.Redirect(w, r, fmt.Sprintf("/repo?name=%s", repoName), http.StatusSeeOther) + return + } + + if action == "upload_file" { + r.ParseMultipartForm(50 << 20) + files := r.MultipartForm.File["file"] + + for _, handler := range files { + file, err := handler.Open() + if err != nil { + continue + } + defer file.Close() + + destPath := filepath.Join(targetPath, handler.Filename) + + if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil { + continue + } + + dst, err := os.Create(destPath) + if err == nil { + io.Copy(dst, file) + dst.Close() + + if strings.ToLower(filepath.Ext(handler.Filename)) == ".zip" { + archive, err := zip.OpenReader(destPath) + if err == nil { + defer archive.Close() + for _, f := range archive.File { + filePath := filepath.Join(targetPath, f.Name) + if !strings.HasPrefix(filePath, filepath.Clean(targetPath)+string(os.PathSeparator)) { + continue + } + if f.FileInfo().IsDir() { + os.MkdirAll(filePath, os.ModePerm) + continue + } + os.MkdirAll(filepath.Dir(filePath), os.ModePerm) + dstFile, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) + if err == nil { + fileInArchive, err := f.Open() + if err == nil { + io.Copy(dstFile, fileInArchive) + fileInArchive.Close() + } + dstFile.Close() + } + } + } + os.Remove(destPath) + } + } + } + + exec.Command("git", "-C", repoRoot, "add", ".").Run() + exec.Command("git", "-C", repoRoot, "commit", "-m", "Add/Update files via upload").Run() + + backUrl := fmt.Sprintf("/repo?name=%s", repoName) + if subPath != "" { + backUrl += fmt.Sprintf("&path=%s", subPath) + } + http.Redirect(w, r, backUrl, http.StatusSeeOther) + return + } + + if action == "create_path" { + name := r.FormValue("name") + itemType := r.FormValue("item_type") + + if name == "" { + http.Error(w, "Name is required", http.StatusBadRequest) + return + } + + newPath := filepath.Join(targetPath, name) + if !strings.HasPrefix(filepath.Clean(newPath), repoRoot) { + http.Error(w, "Invalid path", http.StatusForbidden) + return + } + + if itemType == "folder" { + os.MkdirAll(newPath, 0755) + } else { + if err := os.WriteFile(newPath, []byte(""), 0644); err != nil { + http.Error(w, "Error creating file", http.StatusInternalServerError) + return + } + exec.Command("git", "-C", repoRoot, "add", ".").Run() + exec.Command("git", "-C", repoRoot, "commit", "-m", "Create "+name).Run() + + relPath, _ := filepath.Rel(repoRoot, newPath) + relPath = filepath.ToSlash(relPath) + + http.Redirect(w, r, fmt.Sprintf("/repo?name=%s&path=%s&type=edit", repoName, relPath), http.StatusSeeOther) + return + } + + backUrl := fmt.Sprintf("/repo?name=%s", repoName) + if subPath != "" { + backUrl += fmt.Sprintf("&path=%s", subPath) + } + http.Redirect(w, r, backUrl, http.StatusSeeOther) + return + } + + if action == "delete_repo" { + if repoRoot != "" && strings.HasPrefix(filepath.Clean(repoRoot), filepath.Join(cwd, "repositories")) { + err := os.RemoveAll(repoRoot) + if err != nil { + http.Error(w, "Failed to delete repo: "+err.Error(), http.StatusInternalServerError) + return + } + } + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + if action == "save_file" { + content := r.FormValue("content") + message := r.FormValue("message") + if message == "" { + message = "Update " + filepath.Base(subPath) + } + + if err := os.WriteFile(targetPath, []byte(content), 0644); err != nil { + http.Error(w, "Error writing file: "+err.Error(), http.StatusInternalServerError) + return + } + + exec.Command("git", "-C", repoRoot, "add", ".").Run() + exec.Command("git", "-C", repoRoot, "commit", "-m", message).Run() + + backUrl := fmt.Sprintf("/repo?name=%s&path=%s&type=blob", repoName, subPath) + http.Redirect(w, r, backUrl, http.StatusSeeOther) + return + } + } + + repoData := Repository{ + Name: repoName, + Path: repoRoot, + } + + branches, currentBranch := getBranches(repoRoot) + + var files []FileInfo + var commits []Commit + var readmeContent string + var fileContent string + var commitDiffs []FileDiff + isHistory := (viewType == "history" || viewType == "commits") + isCommit := (viewType == "commit") + isEdit := (viewType == "edit") + isFile := (viewType == "blob") || isEdit + + if isCommit { + hash := r.URL.Query().Get("hash") + if hash != "" { + commitDiffs = getCommitDiff(repoRoot, hash) + } + } else if isHistory { + commits = getCommits(repoRoot) + } else if isFile { + content, err := os.ReadFile(targetPath) + if err == nil { + fileContent = string(content) + } else { + fileContent = "Error reading file." + } + } else { + if subPath == "" { + readmeBytes, err := os.ReadFile(filepath.Join(repoRoot, "README.md")) + if err == nil { + readmeContent = string(readmeBytes) + } + } + + info, err := os.Stat(targetPath) + if err == nil && info.IsDir() { + entries, _ := os.ReadDir(targetPath) + if subPath != "" { + parent := filepath.Dir(subPath) + if parent == "." { + parent = "" + } + files = append(files, FileInfo{Name: "..", IsDir: true}) + } + for _, e := range entries { + if e.Name() == ".git" { + continue + } + info, _ := e.Info() + + // Retrieve the last modification time from the git log. + lastMod := "" + relPath := e.Name() + if subPath != "" { + relPath = filepath.Join(subPath, e.Name()) + } + + gitLogCmd := exec.Command("git", "-C", repoRoot, "log", "-1", "--format=%ar", "--", relPath) + if out, err := gitLogCmd.Output(); err == nil { + lastMod = strings.TrimSpace(string(out)) + } + + files = append(files, FileInfo{ + Name: e.Name(), + IsDir: e.IsDir(), + Size: info.Size(), + LastMod: lastMod, + }) + } + } + } + + var breadcrumbs []Breadcrumb + if subPath != "" { + parts := strings.Split(subPath, "/") + currentAccumulatedPath := "" + for _, part := range parts { + if currentAccumulatedPath == "" { + currentAccumulatedPath = part + } else { + currentAccumulatedPath = filepath.Join(currentAccumulatedPath, part) + } + breadcrumbs = append(breadcrumbs, Breadcrumb{ + Name: part, + Path: currentAccumulatedPath, + }) + } + } + + data := RepoPageData{ + Config: currentConfig, + Repository: repoData, + Files: files, + Readme: readmeContent, + CurrentPath: subPath, + Branches: branches, + CurrentBranch: currentBranch, + Commits: commits, + IsHistory: isHistory, + IsCommit: isCommit, + IsEdit: isEdit, + CommitDiffs: commitDiffs, + Breadcrumbs: breadcrumbs, + } + + tmpl.ExecuteTemplate(w, "repo.html", struct { + RepoPageData + FileContent string + IsFile bool + }{ + RepoPageData: data, + FileContent: fileContent, + IsFile: isFile, + }) + }) + + // getUserAuthorPatterns compiles a list of author identifiers (username, GitHub handle, email) + // associated with the current user. This is used to filter stats in the contribution graph. + getUserAuthorPatterns := func(c Config) []string { + var patterns []string + if c.Username != "" { + patterns = append(patterns, c.Username) + } + if c.GithubUser != "" { + patterns = append(patterns, c.GithubUser) + } + if c.Email != "" { + patterns = append(patterns, c.Email) + } + return patterns + } + + // Register the handler for repository-specific sub-views (e.g., commit history). + http.HandleFunc("/repo/", func(w http.ResponseWriter, r *http.Request) { + pathParts := strings.Split(strings.TrimPrefix(r.URL.Path, "/repo/"), "/") + if len(pathParts) == 0 { + http.NotFound(w, r) + return + } + + repoName := pathParts[0] + action := "" + if len(pathParts) > 1 { + action = pathParts[1] + } + + cwd, _ := os.Getwd() + currentConfig := loadConfig(cwd) + repoRoot := filepath.Join(cwd, "repositories", repoName) + + if _, err := os.Stat(repoRoot); os.IsNotExist(err) { + http.NotFound(w, r) + return + } + + if action == "commits" { + dateStr := r.URL.Query().Get("date") + yearStr := r.URL.Query().Get("year") + monthStr := r.URL.Query().Get("month") + + var commits []Commit + + if dateStr != "" { + commits = getCommitsForDate(repoRoot, dateStr) + } else if yearStr != "" && monthStr != "" { + year, _ := strconv.Atoi(yearStr) + month, _ := strconv.Atoi(monthStr) + if year > 0 && month > 0 { + commits = getCommitsForMonth(repoRoot, year, month) + } else { + commits = getCommits(repoRoot) + } + } else { + commits = getCommits(repoRoot) + } + + branches, currentBranch := getBranches(repoRoot) + + repoData := Repository{ + Name: repoName, + Path: repoRoot, + } + + data := RepoPageData{ + Config: currentConfig, + Repository: repoData, + Branches: branches, + CurrentBranch: currentBranch, + Commits: commits, + IsHistory: true, + } + + if err := tmpl.ExecuteTemplate(w, "repo.html", struct { + RepoPageData + FileContent string + IsFile bool + }{ + RepoPageData: data, + IsFile: false, + }); err != nil { + log.Println("Error executing template:", err) + http.Error(w, "Template Error: "+err.Error(), http.StatusInternalServerError) + } + return + } + + if action == "" { + http.Redirect(w, r, "/repo?name="+repoName, http.StatusSeeOther) + return + } + + http.NotFound(w, r) + }) + + // API endpoint to fetch contribution graph data (lazy loaded). + http.HandleFunc("/api/contribution-graph", func(w http.ResponseWriter, r *http.Request) { + currentConfig := loadConfig(cwd) + scanPath := filepath.Join(cwd, "repositories") + + dateStr := r.URL.Query().Get("date") + yearStr := r.URL.Query().Get("year") + + authorPatterns := getUserAuthorPatterns(currentConfig) + + var graph []ContributionDay + var activity []ActivityMonth + var selectedYear int + + if dateStr != "" { + activity = scanActivityForDate(scanPath, dateStr, authorPatterns) + if t, err := time.Parse("2006-01-02", dateStr); err == nil { + selectedYear = t.Year() + } else { + selectedYear = time.Now().Year() + } + } else { + selectedYear = time.Now().Year() + if yearStr != "" { + if y, err := strconv.Atoi(yearStr); err == nil { + selectedYear = y + } + } + fmt.Printf("DEBUG Graph Request: Year=%d, Patterns=%v\n", selectedYear, authorPatterns) + graph, activity = scanCommitsForYear(scanPath, selectedYear, authorPatterns) + } + + data := struct { + ContributionGraph []ContributionDay + ActivityFeed []ActivityMonth + SelectedYear int + }{ + ContributionGraph: graph, + ActivityFeed: activity, + SelectedYear: selectedYear, + } + + tmpl.ExecuteTemplate(w, "contribution_graph_inner", data) + }) + + // API endpoint to initialize the contribution graph on page load. + http.HandleFunc("/api/contribution-graph-init", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + currentConfig := loadConfig(cwd) + scanPath := filepath.Join(cwd, "repositories") + authorPatterns := getUserAuthorPatterns(currentConfig) + + availableYears := getAvailableYears(scanPath, authorPatterns) + selectedYear := time.Now().Year() + if len(availableYears) > 0 { + selectedYear = availableYears[0] + } + + graph, activity := scanCommitsForYear(scanPath, selectedYear, authorPatterns) + + data := struct { + ContributionGraph []ContributionDay + ActivityFeed []ActivityMonth + SelectedYear int + }{ + ContributionGraph: graph, + ActivityFeed: activity, + SelectedYear: selectedYear, + } + + var buf bytes.Buffer + if err := tmpl.ExecuteTemplate(&buf, "contribution_graph_inner", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + type InitResponse struct { + AvailableYears []int `json:"availableYears"` + SelectedYear int `json:"selectedYear"` + GraphHTML string `json:"graphHTML"` + } + + json.NewEncoder(w).Encode(InitResponse{ + AvailableYears: availableYears, + SelectedYear: selectedYear, + GraphHTML: buf.String(), + }) + }) + + var repoCache []Repository + var repoCacheTime time.Time + var repoCacheMutex sync.Mutex + + // Main dashboard handler. + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + + configPath := filepath.Join(cwd, configFileName) + info, err := os.Stat(configPath) + if os.IsNotExist(err) || info.Size() == 0 { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + + currentConfig := loadConfig(cwd) + if currentConfig.Username == "" { + http.Redirect(w, r, "/setup", http.StatusSeeOther) + return + } + + activeTab := r.URL.Query().Get("tab") + if activeTab == "" { + activeTab = "overview" + } + + scanPath := filepath.Join(cwd, "repositories") + os.MkdirAll(scanPath, 0755) + + os.MkdirAll(scanPath, 0755) + repoCacheMutex.Lock() + if time.Since(repoCacheTime) > 1*time.Minute || len(repoCache) == 0 { + currentConfig.Repositories = scanRepositories(scanPath) + repoCache = currentConfig.Repositories + repoCacheTime = time.Now() + } else { + currentConfig.Repositories = repoCache + } + repoCacheMutex.Unlock() + + var availableYears []int + var contributionGraph []ContributionDay + selectedYear := time.Now().Year() + + var specialReadme string + if currentConfig.GithubUser != "" { + readmePath := filepath.Join(scanPath, currentConfig.GithubUser, "README.md") + if content, err := os.ReadFile(readmePath); err == nil { + specialReadme = string(content) + } + } + + data := PageData{ + Config: currentConfig, + ActiveTab: activeTab, + SpecialReadme: specialReadme, + ContributionGraph: contributionGraph, + AvailableYears: availableYears, + SelectedYear: selectedYear, + } + tmpl.ExecuteTemplate(w, "index.html", data) + }) + + http.Handle("/static/", http.FileServer(http.FS(webFS))) + + http.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(filepath.Join(cwd, "uploads"))))) + + fmt.Println("πŸš€ PortaGit est en ligne !") + fmt.Println("🌍 Ouvrez votre navigateur sur : http://localhost:8080") + + if err := http.ListenAndServe(":8080", nil); err != nil { + log.Fatal(err) + } +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..3abfda1 --- /dev/null +++ b/types.go @@ -0,0 +1,145 @@ +package main + +// Config represents the global configuration for the PortaGit application. +// It includes user profile information, theme settings, and cached repository data. +type Config struct { + Username string `json:"username"` + GithubUser string `json:"github_user"` + Bio string `json:"bio"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Theme string `json:"theme"` + CustomColors CustomColors `json:"custom_colors"` + Socials Socials `json:"socials"` + PinnedRepos []string `json:"pinned_repos"` + Repositories []Repository `json:"repositories"` +} + +// Repository holds metadata about a specific git repository. +type Repository struct { + Name string `json:"name"` + Description string `json:"description"` + Language string `json:"language"` + Path string `json:"path"` + UpdatedAt string `json:"updated_at"` +} + +// CustomColors defines the color palette used in the UI theme. +type CustomColors struct { + BgColor string `json:"bg_color"` + CardBg string `json:"card_bg"` + BorderColor string `json:"border_color"` + AccentColor string `json:"accent_color"` + TextPrimary string `json:"text_primary"` + TextSecondary string `json:"text_secondary"` + TextMuted string `json:"text_muted"` + ButtonText string `json:"button_text"` +} + +// Socials contains links to the user's social media profiles. +type Socials struct { + Linkedin string `json:"linkedin"` + Github string `json:"github"` + Website string `json:"website"` +} + +// PageData is the standard data structure passed to HTML templates. +type PageData struct { + Config Config + ActiveTab string + SpecialReadme string + ContributionGraph []ContributionDay + AvailableYears []int + SelectedYear int + ActivityFeed []ActivityMonth +} + +// ActivityMonth represents aggregated contribution activity for a specific month. +type ActivityMonth struct { + MonthName string + YearInt int + MonthInt int + Date string + TotalCommits int + RepoCount int + Items []ActivityItem +} + +// ActivityItem represents contribution counts for a single repository within a month. +type ActivityItem struct { + RepoName string + Commits int +} + +// ContributionDay represents a single day in the contribution graph. +type ContributionDay struct { + Date string + Count int + Color string +} + +// SettingsData holds data required for rendering the settings page. +type SettingsData struct { + Config Config + CurrentTab string +} + +// RepoPageData holds data required for rendering repository-specific views. +type RepoPageData struct { + Config Config + Repository Repository + Files []FileInfo + Readme string + CurrentPath string + Branches []string + CurrentBranch string + Commits []Commit + IsHistory bool + IsCommit bool + IsEdit bool + CommitDiffs []FileDiff + Breadcrumbs []Breadcrumb +} + +// Breadcrumb represents a single navigation item in the repository path. +type Breadcrumb struct { + Name string + Path string +} + +// FileInfo represents metadata for a file or directory in the file browser. +type FileInfo struct { + Name string + IsDir bool + Size int64 + LastMod string +} + +// Commit represents a single git commit. +type Commit struct { + Hash string + Message string + Author string + Date string +} + +// FileDiff represents the difference content for a specific file in a commit. +type FileDiff struct { + Name string + Content string +} + +// GithubRepo is used for parsing GitHub API responses for repositories. +type GithubRepo struct { + Name string `json:"name"` + CloneUrl string `json:"clone_url"` + Description string `json:"description"` +} + +// GithubUser is used for parsing GitHub API responses for user profiles. +type GithubUser struct { + Login string `json:"login"` + Name string `json:"name"` + Bio string `json:"bio"` + AvatarUrl string `json:"avatar_url"` +}