Téléverser les fichiers vers "/"

This commit is contained in:
RainbowYoshi 2026-01-31 21:51:06 +00:00
commit ebd4775d5e
5 changed files with 1326 additions and 0 deletions

62
README.md Normal file
View File

@ -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.

28
go.mod Normal file
View File

@ -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
)

109
go.sum Normal file
View File

@ -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=

982
main.go Normal file
View File

@ -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)
}
}

145
types.go Normal file
View File

@ -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"`
}