Compare commits
No commits in common. "master" and "feature/time_machine_page" have entirely different histories.
master
...
feature/ti
|
@ -16,7 +16,6 @@ local/backups
|
|||
/tmp
|
||||
*.exe
|
||||
.DS_Store
|
||||
__debug_bin*
|
||||
|
||||
# vim session saves
|
||||
Session.vim
|
||||
|
|
|
@ -10,10 +10,12 @@ We want the website to be a great example of Handmade software on the web. We en
|
|||
|
||||
You will need the following software installed:
|
||||
|
||||
- Go 1.21 or newer: https://go.dev/
|
||||
- Go 1.18 or 1.19: https://go.dev/
|
||||
|
||||
You can download Go directly from the website, or install it through major package managers. If you already have Go installed, but are unsure of the version, you can check by running `go version`.
|
||||
|
||||
**PLEASE NOTE:** Go 1.20 currently does not work due to a bug in a third-party library. See [this issue](https://git.handmade.network/hmn/hmn/issues/59#issuecomment-1335).
|
||||
|
||||
- Postgres: https://www.postgresql.org/
|
||||
|
||||
Any Postgres installation should work fine, although less common distributions may not work as nicely with our scripts out of the box. On Mac, [Postgres.app](https://postgresapp.com/) is recommended.
|
||||
|
|
|
@ -4,7 +4,6 @@ const RecvAddress = "admin@example.com"
|
|||
const RecvName = "Admin"
|
||||
const FromName = "From Name"
|
||||
const FromAddress = "from@address.com"
|
||||
const ServerUsername = "username"
|
||||
const ServerPassword = "password"
|
||||
const FromAddressPassword = "password"
|
||||
const ServerAddress = "server.address"
|
||||
const ServerPort = 587
|
||||
|
|
|
@ -46,7 +46,7 @@ func sendMail(toAddress, toName, subject, contentHtml string) error {
|
|||
)
|
||||
return smtp.SendMail(
|
||||
fmt.Sprintf("%s:%d", ServerAddress, ServerPort),
|
||||
smtp.PlainAuth("", ServerUsername, ServerPassword, ServerAddress),
|
||||
smtp.PlainAuth("", FromAddress, FromAddressPassword, ServerAddress),
|
||||
FromAddress,
|
||||
[]string{toAddress},
|
||||
contents,
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
- [ ] Export with [DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) CLI 2.39.1
|
||||
- [ ] Export with [DiscordChatExporter](https://github.com/Tyrrrz/DiscordChatExporter) CLI 2.34
|
||||
|
||||
```
|
||||
DiscordChatExporter.Cli.exe export -c [thread-id] -t [token] -o [fishbowl].html --media
|
||||
|
@ -49,7 +49,7 @@
|
|||
go run twemoji.go [fishbowl]-dragged.html files [fishbowl]-twemojied.html
|
||||
```
|
||||
|
||||
- [ ] Fix timestamps, validate they look correct
|
||||
- [ ] Fix timestamps
|
||||
|
||||
```
|
||||
go run timestamps.go [fishbowl]-twemojied.html [fishbowl]-timestamped.html
|
||||
|
@ -59,7 +59,7 @@
|
|||
- [ ] Create fishbowl folder under `hmn/src/templates/src/fishbowls/`
|
||||
- [ ] Copy timestamped html and files, rename html
|
||||
- [ ] Remove everything from html but chatlog
|
||||
- [ ] Remove js, css and ggsans from files
|
||||
- [ ] Remove js, css and whitney from files
|
||||
- [ ] Add content path to `fishbowl.go`
|
||||
- [ ] Test locally
|
||||
- [ ] Submit a pull request
|
||||
|
|
|
@ -4,13 +4,11 @@ import (
|
|||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) != 3 {
|
||||
fmt.Println("Usage: go run timestamps.go <fishbowl>.html <fishbowl>-timestamped.html")
|
||||
fmt.Println("Usage: go run timestamps.go [fishbowl].html [fishbowl]-timestamped.html")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
|
@ -24,19 +22,17 @@ func main() {
|
|||
|
||||
html := string(htmlBytes)
|
||||
|
||||
regex := regexp.MustCompile(
|
||||
`(<span class="?chatlog__timestamp"?><a href=[^>]+>)(\d+)/(\d+)/(\d+)( [^<]+</a></span>)`,
|
||||
regex, err := regexp.Compile(
|
||||
"(<span class=\"chatlog__timestamp\">)(\\d+)-([A-Za-z]+)-(\\d+)( [^<]+</span>)",
|
||||
)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
htmlOut := regex.ReplaceAllStringFunc(html, func(s string) string {
|
||||
match := regex.FindStringSubmatch(s)
|
||||
month, err := strconv.ParseInt(match[2], 10, 64)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
monthStr := time.Month(month).String()
|
||||
return fmt.Sprintf("%s%s %s, %s%s", match[1], monthStr, match[3], match[4], match[5])
|
||||
})
|
||||
htmlOut := regex.ReplaceAllString(
|
||||
html,
|
||||
"$1$3 $2, 20$4$5",
|
||||
)
|
||||
|
||||
err = os.WriteFile(htmlOutPath, []byte(htmlOut), 0666)
|
||||
if err != nil {
|
||||
|
|
11
go.mod
|
@ -1,6 +1,6 @@
|
|||
module git.handmade.network/hmn/hmn
|
||||
|
||||
go 1.21
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/HandmadeNetwork/golorem v0.0.0-20220507185207-414965a3a817
|
||||
|
@ -11,7 +11,6 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/credentials v1.3.3
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.13.0
|
||||
github.com/aws/smithy-go v1.7.0
|
||||
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f
|
||||
github.com/frustra/bbcode v0.0.0-20201127003707-6ef347fbe1c8
|
||||
github.com/go-stack/stack v1.8.0
|
||||
github.com/google/uuid v1.2.0
|
||||
|
@ -24,6 +23,7 @@ require (
|
|||
github.com/stretchr/testify v1.8.1
|
||||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300
|
||||
github.com/teacat/noire v1.1.0
|
||||
github.com/wellington/go-libsass v0.9.2
|
||||
github.com/yuin/goldmark v1.4.13
|
||||
github.com/yuin/goldmark-highlighting v0.0.0-20210516132338-9216f9c5aa01
|
||||
golang.org/x/crypto v0.6.0
|
||||
|
@ -44,7 +44,6 @@ require (
|
|||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dlclark/regexp2 v1.4.0 // indirect
|
||||
github.com/evanw/esbuild v0.21.4
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/imdario/mergo v0.3.12 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.0.0 // indirect
|
||||
|
@ -57,12 +56,12 @@ require (
|
|||
github.com/mitchellh/reflectwalk v1.0.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/teambition/rrule-go v1.7.2 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/net v0.6.0 // indirect
|
||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 // indirect
|
||||
golang.org/x/sys v0.5.0 // indirect
|
||||
golang.org/x/text v0.7.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
|
|
24
go.sum
|
@ -88,10 +88,6 @@ github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55k
|
|||
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/dlclark/regexp2 v1.4.0 h1:F1rxgk7p4uKjwIQxBs9oAXe5CqrXlCduYEJvrF4u93E=
|
||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
||||
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f h1:feGUUxxvOtWVOhTko8Cbmp33a+tU0IMZxMEmnkoAISQ=
|
||||
github.com/emersion/go-ical v0.0.0-20220601085725-0864dccc089f/go.mod h1:2MKFUgfNMULRxqZkadG1Vh44we3y5gJAtTBlVsx1BKQ=
|
||||
github.com/evanw/esbuild v0.21.4 h1:pe4SEQMoR1maEjhgWPEPWmUy11Jp6nidxd1mOvMrFFU=
|
||||
github.com/evanw/esbuild v0.21.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
|
@ -117,9 +113,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ
|
|||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6 h1:BKbKCqvP6I+rmFHt06ZmyQtvB8xAkWdhFyr0ZUNZcxQ=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
|
@ -185,7 +180,6 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxv
|
|||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
|
@ -281,9 +275,9 @@ github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300 h1:XQdibLKagjdevRB6vA
|
|||
github.com/tcolgate/mp3 v0.0.0-20170426193717-e79c5a46d300/go.mod h1:FNa/dfN95vAYCNFrIKRrlRo+MBLbwmR9Asa5f2ljmBI=
|
||||
github.com/teacat/noire v1.1.0 h1:5IgJ1H8jodiSSYnrVadV2JjbAnEgCCjYUQxSUuaQ7Sg=
|
||||
github.com/teacat/noire v1.1.0/go.mod h1:cetGlnqr+9yKJcFgRgYXOWJY66XIrrjUsGBwNlNNtAk=
|
||||
github.com/teambition/rrule-go v1.7.2 h1:goEajFWYydfCgavn2m/3w5U+1b3PGqPUHx/fFSVfTy0=
|
||||
github.com/teambition/rrule-go v1.7.2/go.mod h1:mBJ1Ht5uboJ6jexKdNUJg2NcwP8uUMNvStWXlJD3MvU=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/wellington/go-libsass v0.9.2 h1:6Ims04UDdBs6/CGSVK5JC8FNikR5ssrsMMKE/uaO5Q8=
|
||||
github.com/wellington/go-libsass v0.9.2/go.mod h1:mxgxgam0N0E+NAUMHLcu20Ccfc3mVpDkyrLDayqfiTs=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
github.com/yuin/goldmark v1.3.6/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE=
|
||||
|
@ -310,8 +304,6 @@ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL
|
|||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
|
||||
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
|
||||
golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
|
||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
|
||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.0.0-20210628002857-a66eb6448b8d h1:RNPAfi2nHY7C2srAV8A49jpsYr0ADedCk1wq6fTMTvs=
|
||||
|
@ -341,6 +333,8 @@ golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.6.0 h1:L4ZwwTvKW9gr0ZMS1yrHD9GZhIuVjOBBnaKH+SPQK0Q=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
|
@ -350,8 +344,8 @@ golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJ
|
|||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7 h1:ZrnxWX62AgTKOSagEqxvb3ffipvEDX2pl7E1TdqLqIc=
|
||||
golang.org/x/sync v0.0.0-20220923202941-7f9b1623fab7/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
|
@ -371,7 +365,6 @@ golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7w
|
|||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
@ -402,6 +395,8 @@ golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtn
|
|||
golang.org/x/tools v0.0.0-20191112195655-aa38f8e97acc/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
|
@ -427,7 +422,6 @@ gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLks
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/ini.v1 v1.51.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
|
||||
|
|
|
@ -1,20 +0,0 @@
|
|||
#!/usr/bin/env python3
|
||||
|
||||
import boto3
|
||||
|
||||
# You must already have configured your "AWS" (DigitalOcean) credentials via the AWS CLI.
|
||||
|
||||
s3 = boto3.resource("s3")
|
||||
bucket = s3.Bucket("hmn-backup")
|
||||
for obj in bucket.objects.filter(Prefix="db"):
|
||||
print(obj.key)
|
||||
|
||||
print()
|
||||
print("Above is a list of all the available database backups.")
|
||||
print("Enter the name of the one you would like to download (e.g. \"hmn_pg_dump_live_2023-09-24\"):")
|
||||
filename = input()
|
||||
|
||||
s3 = boto3.client("s3")
|
||||
s3.download_file("hmn-backup", f"db/{filename}", f"local/backups/{filename}")
|
||||
|
||||
print(f"Downloaded {filename} to local/backups.")
|
|
@ -0,0 +1,15 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
s3cmd ls s3://hmn-backup/db/
|
||||
|
||||
echo ""
|
||||
echo "Above is a list of all the available database backups."
|
||||
echo "Enter the name of the one you would like to download (e.g. \"hmn_pg_dump_live_2021-09-01\"):"
|
||||
read filename
|
||||
|
||||
s3cmd get --force s3://hmn-backup/db/$filename ./local/backups/$filename
|
||||
|
||||
echo ""
|
||||
echo "Downloaded $filename to local/backups."
|
|
@ -1 +0,0 @@
|
|||
boto3
|
2
main.go
|
@ -3,7 +3,7 @@ package main
|
|||
import (
|
||||
_ "git.handmade.network/hmn/hmn/src/admintools"
|
||||
_ "git.handmade.network/hmn/hmn/src/assets"
|
||||
_ "git.handmade.network/hmn/hmn/src/buildcss/cmd"
|
||||
_ "git.handmade.network/hmn/hmn/src/buildscss"
|
||||
_ "git.handmade.network/hmn/hmn/src/discord/cmd"
|
||||
_ "git.handmade.network/hmn/hmn/src/initimage"
|
||||
_ "git.handmade.network/hmn/hmn/src/migration"
|
||||
|
|
Before Width: | Height: | Size: 118 KiB |
Before Width: | Height: | Size: 14 KiB |
|
@ -94,7 +94,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-symbol, .fishbowl .chatlog__reply-symbol {
|
||||
.fishbowl .chatlog__reference-symbol {
|
||||
height: 10px;
|
||||
margin: 6px 4px 4px 36px;
|
||||
border-left: 2px solid #4f545c;
|
||||
|
@ -122,7 +122,7 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference, .fishbowl .chatlog__reply {
|
||||
.fishbowl .chatlog__reference {
|
||||
display: flex;
|
||||
margin-bottom: 0.15rem;
|
||||
align-items: center;
|
||||
|
@ -133,53 +133,53 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-avatar, .fishbowl .chatlog__reply-avatar {
|
||||
.fishbowl .chatlog__reference-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-author, .fishbowl .chatlog__reply-author {
|
||||
.fishbowl .chatlog__reference-author {
|
||||
margin-right: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-content, .fishbowl .chatlog__reply-content {
|
||||
.fishbowl .chatlog__reference-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link, .fishbowl .chatlog__reply-link {
|
||||
.fishbowl .chatlog__reference-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link *, .fishbowl .chatlog__reply-link * {
|
||||
.fishbowl .chatlog__reference-link * {
|
||||
display: inline;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .hljs, .fishbowl .chatlog__reply-link .hljs {
|
||||
.fishbowl .chatlog__reference-link .hljs {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote, .fishbowl .chatlog__reply-link .chatlog__markdown-quote {
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote {
|
||||
display: inline
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre, .fishbowl .chatlog__reply-link .chatlog__markdown-pre {
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre {
|
||||
display: inline
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link:hover, .fishbowl .chatlog__reply-link:hover {
|
||||
.fishbowl .chatlog__reference-link:hover {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler), .fishbowl .chatlog__reply-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-edited-timestamp, .fishbowl .chatlog__reply-edited-timestamp {
|
||||
.fishbowl .chatlog__reference-edited-timestamp {
|
||||
margin-left: 0.25rem;
|
||||
color: #a3a6aa;
|
||||
font-size: 0.75rem;
|
||||
|
|
|
@ -95,7 +95,7 @@
|
|||
text-align: center;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-symbol, .fishbowl .chatlog__reply-symbol {
|
||||
.fishbowl .chatlog__reference-symbol {
|
||||
height: 10px;
|
||||
margin: 6px 4px 4px 36px;
|
||||
border-left: 2px solid #c7ccd1;
|
||||
|
@ -123,7 +123,7 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference, .fishbowl .chatlog__reply {
|
||||
.fishbowl .chatlog__reference {
|
||||
display: flex;
|
||||
margin-bottom: 0.15rem;
|
||||
align-items: center;
|
||||
|
@ -134,53 +134,53 @@
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-avatar, .fishbowl .chatlog__reply-avatar {
|
||||
.fishbowl .chatlog__reference-avatar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: 0.25rem;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-author, .fishbowl .chatlog__reply-author {
|
||||
.fishbowl .chatlog__reference-author {
|
||||
margin-right: 0.3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-content, .fishbowl .chatlog__reply-content {
|
||||
.fishbowl .chatlog__reference-content {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link, .fishbowl .chatlog__reply-link {
|
||||
.fishbowl .chatlog__reference-link {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link *, .fishbowl .chatlog__reply-link * {
|
||||
.fishbowl .chatlog__reference-link * {
|
||||
display: inline;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .hljs, .fishbowl .chatlog__reply-link .hljs {
|
||||
.fishbowl .chatlog__reference-link .hljs {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote, .fishbowl .chatlog__reply-link .chatlog__markdown-quote {
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-quote {
|
||||
display: inline
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre, .fishbowl .chatlog__reply-link .chatlog__markdown-pre {
|
||||
.fishbowl .chatlog__reference-link .chatlog__markdown-pre {
|
||||
display: inline
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link:hover, .fishbowl .chatlog__reply-link:hover {
|
||||
.fishbowl .chatlog__reference-link:hover {
|
||||
color: #2f3136;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler), .fishbowl .chatlog__reply-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||
.fishbowl .chatlog__reference-link:hover *:not(.chatlog__markdown-spoiler) {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.fishbowl .chatlog__reference-edited-timestamp, .fishbowl .chatlog__reply-edited-timestamp {
|
||||
.fishbowl .chatlog__reference-edited-timestamp {
|
||||
margin-left: 0.25rem;
|
||||
color: #5e6772;
|
||||
font-size: 0.75rem;
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
document.addEventListener("DOMContentLoaded", () => {
|
||||
for (const countdown of document.querySelectorAll(".countdown")) {
|
||||
const deadline = countdown.getAttribute("data-deadline");
|
||||
const deadlineDate = new Date(parseInt(deadline, 10) * 1000);
|
||||
|
||||
function updateCountdown() {
|
||||
const remainingMs = deadlineDate.getTime() - new Date().getTime();
|
||||
const remainingMinutes = remainingMs / 1000 / 60;
|
||||
const remainingHours = remainingMinutes / 60;
|
||||
const remainingDays = remainingHours / 24; // no daylight savings transitions during the jam mmkay
|
||||
|
||||
let str = "imminently";
|
||||
if (remainingMinutes < 60) {
|
||||
str = `in ${Math.ceil(remainingMinutes)} ${
|
||||
remainingMinutes === 1 ? "minute" : "minutes"
|
||||
}`;
|
||||
} else if (remainingHours < 24) {
|
||||
str = `in ${Math.ceil(remainingHours)} ${
|
||||
remainingHours === 1 ? "hour" : "hours"
|
||||
}`;
|
||||
} else {
|
||||
str = `in ${Math.ceil(remainingDays)} ${
|
||||
remainingDays === 1 ? "day" : "days"
|
||||
}`;
|
||||
}
|
||||
|
||||
countdown.innerText = str;
|
||||
}
|
||||
|
||||
updateCountdown();
|
||||
setInterval(updateCountdown, 1000 * 60);
|
||||
}
|
||||
});
|
|
@ -1,28 +1,20 @@
|
|||
function ImageSelector(form, maxFileSize, container, {
|
||||
defaultImageUrl = "",
|
||||
onUpdate = (url) => {},
|
||||
} = {}) {
|
||||
function ImageSelector(form, maxFileSize, container, defaultImageUrl) {
|
||||
this.form = form;
|
||||
this.maxFileSize = maxFileSize;
|
||||
this.fileInput = container.querySelector(".imginput");
|
||||
this.removeImageInput = container.querySelector(".imginput-remove");
|
||||
this.fileInput = container.querySelector(".image_input");
|
||||
this.removeImageInput = container.querySelector(".remove_input");
|
||||
this.imageEl = container.querySelector("img");
|
||||
this.container = container.querySelector(".imginput-container");
|
||||
this.resetLink = container.querySelector(".imginput-reset-link");
|
||||
this.removeLink = container.querySelector(".imginput-remove-link");
|
||||
this.filenameText = container.querySelector(".imginput-filename");
|
||||
this.errorEl = container.querySelector(".error");
|
||||
this.originalImageUrl = this.imageEl.getAttribute("data-imginput-original");
|
||||
this.originalImageFilename = this.imageEl.getAttribute("data-imginput-original-filename");
|
||||
this.resetLink = container.querySelector(".reset");
|
||||
this.removeLink = container.querySelector(".remove");
|
||||
this.originalImageUrl = this.imageEl.getAttribute("data-original");
|
||||
this.currentImageUrl = this.originalImageUrl;
|
||||
this.defaultImageUrl = defaultImageUrl;
|
||||
this.onUpdate = onUpdate;
|
||||
this.defaultImageUrl = defaultImageUrl || "";
|
||||
|
||||
this.fileInput.value = "";
|
||||
this.removeImageInput.value = "";
|
||||
|
||||
this.setImageUrl(this.originalImageUrl, true);
|
||||
this.updatePreview();
|
||||
this.setImageUrl(this.originalImageUrl);
|
||||
this.updateButtons();
|
||||
|
||||
this.fileInput.addEventListener("change", function(ev) {
|
||||
if (this.fileInput.files.length > 0) {
|
||||
|
@ -41,16 +33,12 @@ function ImageSelector(form, maxFileSize, container, {
|
|||
}
|
||||
}
|
||||
|
||||
ImageSelector.prototype.openFileInput = function() {
|
||||
this.fileInput.click();
|
||||
}
|
||||
|
||||
ImageSelector.prototype.handleNewImageFile = function(file) {
|
||||
if (file) {
|
||||
this.updateSizeLimit(file.size);
|
||||
this.removeImageInput.value = "";
|
||||
this.setImageUrl(URL.createObjectURL(file));
|
||||
this.updatePreview(file);
|
||||
this.updateButtons();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -59,7 +47,7 @@ ImageSelector.prototype.removeImage = function() {
|
|||
this.fileInput.value = "";
|
||||
this.removeImageInput.value = "true";
|
||||
this.setImageUrl(this.defaultImageUrl);
|
||||
this.updatePreview(null);
|
||||
this.updateButtons();
|
||||
};
|
||||
|
||||
ImageSelector.prototype.resetImage = function() {
|
||||
|
@ -67,7 +55,7 @@ ImageSelector.prototype.resetImage = function() {
|
|||
this.fileInput.value = "";
|
||||
this.removeImageInput.value = "";
|
||||
this.setImageUrl(this.originalImageUrl);
|
||||
this.updatePreview(null);
|
||||
this.updateButtons();
|
||||
};
|
||||
|
||||
ImageSelector.prototype.updateSizeLimit = function(size) {
|
||||
|
@ -80,13 +68,11 @@ ImageSelector.prototype.updateSizeLimit = function(size) {
|
|||
};
|
||||
|
||||
ImageSelector.prototype.setError = function(error) {
|
||||
this.errorEl.textContent = error;
|
||||
this.errorEl.hidden = !error;
|
||||
this.fileInput.setCustomValidity(error);
|
||||
this.fileInput.reportValidity();
|
||||
}
|
||||
|
||||
ImageSelector.prototype.setImageUrl = function(url, initial = false) {
|
||||
ImageSelector.prototype.setImageUrl = function(url) {
|
||||
this.currentImageUrl = url;
|
||||
this.imageEl.src = url;
|
||||
if (url.length > 0) {
|
||||
|
@ -94,30 +80,21 @@ ImageSelector.prototype.setImageUrl = function(url, initial = false) {
|
|||
} else {
|
||||
this.imageEl.style.display = "none";
|
||||
}
|
||||
this.url = url;
|
||||
if (!initial) {
|
||||
this.onUpdate(url);
|
||||
}
|
||||
};
|
||||
|
||||
ImageSelector.prototype.updatePreview = function(file) {
|
||||
const showReset = (
|
||||
this.originalImageUrl
|
||||
&& this.originalImageUrl != this.defaultImageUrl
|
||||
&& this.originalImageUrl != this.currentImageUrl
|
||||
);
|
||||
const showRemove = (
|
||||
!this.fileInput.required
|
||||
&& this.currentImageUrl != this.defaultImageUrl
|
||||
);
|
||||
this.resetLink.hidden = !showReset;
|
||||
this.removeLink.hidden = !showRemove;
|
||||
ImageSelector.prototype.updateButtons = function() {
|
||||
if ((this.originalImageUrl.length > 0 && this.originalImageUrl != this.defaultImageUrl)
|
||||
&& this.currentImageUrl != this.originalImageUrl) {
|
||||
|
||||
if (this.currentImageUrl == this.originalImageUrl) {
|
||||
this.filenameText.innerText = this.originalImageFilename;
|
||||
this.resetLink.style.display = "inline-block";
|
||||
} else {
|
||||
this.filenameText.innerText = file ? file.name : "";
|
||||
this.resetLink.style.display = "none";
|
||||
}
|
||||
|
||||
this.container.hidden = !this.currentImageUrl;
|
||||
if (!this.fileInput.required && this.currentImageUrl != this.defaultImageUrl) {
|
||||
this.removeLink.style.display = "inline-block";
|
||||
} else {
|
||||
this.removeLink.style.display = "none";
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +0,0 @@
|
|||
function rem2px(rem) {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
|
@ -25,7 +25,6 @@ function doOnce(f) {
|
|||
}
|
||||
}
|
||||
|
||||
// TODO(redesign): Fix snippet editing, given that snippets are now presented very differently.
|
||||
function makeShowcaseItem(timelineItem) {
|
||||
const timestamp = showcaseTimestamp(timelineItem.date);
|
||||
|
||||
|
@ -46,7 +45,7 @@ function makeShowcaseItem(timelineItem) {
|
|||
createModalContentFunc = () => {
|
||||
const modalImage = document.createElement('img');
|
||||
modalImage.src = timelineItem.asset_url;
|
||||
modalImage.classList.add('mw-100', 'maxh-60vh');
|
||||
modalImage.classList.add('mw-100', 'mh-60vh');
|
||||
return modalImage;
|
||||
};
|
||||
|
||||
|
@ -77,7 +76,7 @@ function makeShowcaseItem(timelineItem) {
|
|||
modalVideo.preload = 'metadata';
|
||||
}
|
||||
modalVideo.controls = true;
|
||||
modalVideo.classList.add('mw-100', 'maxh-60vh');
|
||||
modalVideo.classList.add('mw-100', 'mh-60vh');
|
||||
return modalVideo;
|
||||
};
|
||||
|
||||
|
|
|
@ -9,7 +9,7 @@ function readableByteSize(numBytes) {
|
|||
"gb"
|
||||
];
|
||||
let scale = 0;
|
||||
while (numBytes > 1024 && scale < scales.length - 1) {
|
||||
while (numBytes > 1024 && scale < scales.length-1) {
|
||||
numBytes /= 1024;
|
||||
scale++;
|
||||
}
|
||||
|
@ -24,16 +24,11 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
let attachmentChanged = false;
|
||||
let hasAttachment = false;
|
||||
snippetEdit.redirect.value = location.href;
|
||||
if (ownerAvatar) {
|
||||
snippetEdit.avatarImg.src = ownerAvatar;
|
||||
snippetEdit.avatarLink.href = ownerUrl;
|
||||
snippetEdit.avatarImg.hidden = false;
|
||||
} else {
|
||||
snippetEdit.avatarImg.hidden = true;
|
||||
}
|
||||
snippetEdit.avatarImg.src = ownerAvatar;
|
||||
snippetEdit.avatarLink.href = ownerUrl;
|
||||
snippetEdit.username.textContent = ownerName;
|
||||
snippetEdit.username.href = ownerUrl;
|
||||
snippetEdit.date.textContent = new Intl.DateTimeFormat([], { month: "2-digit", day: "2-digit", year: "numeric" }).format(date);
|
||||
snippetEdit.date.textContent = new Intl.DateTimeFormat([], {month: "2-digit", day: "2-digit", year: "numeric"}).format(date);
|
||||
snippetEdit.text.value = text;
|
||||
if (attachmentElement) {
|
||||
originalAttachment = attachmentElement.cloneNode(true);
|
||||
|
@ -61,7 +56,7 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
updateProjectSelector();
|
||||
|
||||
if (originalSnippetEl) {
|
||||
snippetEdit.cancelLink.addEventListener("click", function () {
|
||||
snippetEdit.cancelLink.addEventListener("click", function() {
|
||||
cancel();
|
||||
});
|
||||
} else {
|
||||
|
@ -83,7 +78,7 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
if (proj.id == stickyProjectId) {
|
||||
projEl.removeButton.remove();
|
||||
} else {
|
||||
projEl.removeButton.addEventListener("click", function (ev) {
|
||||
projEl.removeButton.addEventListener("click", function(ev) {
|
||||
projEl.root.remove();
|
||||
updateProjectSelector();
|
||||
});
|
||||
|
@ -131,7 +126,7 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
option.textContent = remainingProjects[i].name;
|
||||
projectSelector.appendChild(option);
|
||||
}
|
||||
projectSelector.addEventListener("change", function (ev) {
|
||||
projectSelector.addEventListener("change", function(ev) {
|
||||
if (projectSelector.selectedOptions.length > 0) {
|
||||
let selected = projectSelector.selectedOptions[0];
|
||||
if (selected.value != "") {
|
||||
|
@ -242,33 +237,33 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
}
|
||||
}
|
||||
|
||||
snippetEdit.uploadLink.addEventListener("click", function () {
|
||||
snippetEdit.uploadLink.addEventListener("click", function() {
|
||||
snippetEdit.file.click();
|
||||
});
|
||||
|
||||
snippetEdit.removeLink.addEventListener("click", function () {
|
||||
snippetEdit.removeLink.addEventListener("click", function() {
|
||||
clearAttachment(false);
|
||||
});
|
||||
|
||||
snippetEdit.replaceLink.addEventListener("click", function () {
|
||||
snippetEdit.replaceLink.addEventListener("click", function() {
|
||||
snippetEdit.file.click();
|
||||
});
|
||||
|
||||
snippetEdit.resetLink.addEventListener("click", function () {
|
||||
snippetEdit.resetLink.addEventListener("click", function() {
|
||||
clearAttachment(true);
|
||||
});
|
||||
|
||||
snippetEdit.uploadResetLink.addEventListener("click", function () {
|
||||
snippetEdit.uploadResetLink.addEventListener("click", function() {
|
||||
clearAttachment(true);
|
||||
});
|
||||
|
||||
snippetEdit.file.addEventListener("change", function () {
|
||||
snippetEdit.file.addEventListener("change", function() {
|
||||
if (snippetEdit.file.files.length > 0) {
|
||||
setFile(snippetEdit.file.files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
snippetEdit.root.addEventListener("dragover", function (ev) {
|
||||
snippetEdit.root.addEventListener("dragover", function(ev) {
|
||||
let effect = "none";
|
||||
for (let i = 0; i < ev.dataTransfer.items.length; ++i) {
|
||||
if (ev.dataTransfer.items[i].kind.toLowerCase() == "file") {
|
||||
|
@ -282,7 +277,7 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
|
||||
let enterCounter = 0;
|
||||
|
||||
snippetEdit.root.addEventListener("dragenter", function (ev) {
|
||||
snippetEdit.root.addEventListener("dragenter", function(ev) {
|
||||
enterCounter++;
|
||||
let droppable = Array.from(ev.dataTransfer.items).some(
|
||||
item => item.kind.toLowerCase() === "file"
|
||||
|
@ -292,14 +287,14 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
}
|
||||
});
|
||||
|
||||
snippetEdit.root.addEventListener("dragleave", function (ev) {
|
||||
snippetEdit.root.addEventListener("dragleave", function(ev) {
|
||||
enterCounter--;
|
||||
if (enterCounter == 0) {
|
||||
snippetEdit.root.classList.remove("drop");
|
||||
}
|
||||
});
|
||||
|
||||
snippetEdit.root.addEventListener("drop", function (ev) {
|
||||
snippetEdit.root.addEventListener("drop", function(ev) {
|
||||
enterCounter = 0;
|
||||
snippetEdit.root.classList.remove("drop");
|
||||
|
||||
|
@ -310,18 +305,18 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
ev.preventDefault();
|
||||
});
|
||||
|
||||
snippetEdit.text.addEventListener("paste", function (ev) {
|
||||
snippetEdit.text.addEventListener("paste", function(ev) {
|
||||
const files = ev.clipboardData?.files ?? [];
|
||||
if (files.length > 0) {
|
||||
setFile(files[0]);
|
||||
}
|
||||
});
|
||||
|
||||
snippetEdit.text.addEventListener("input", function (ev) {
|
||||
snippetEdit.text.addEventListener("input", function(ev) {
|
||||
validate();
|
||||
});
|
||||
|
||||
snippetEdit.saveButton.addEventListener("click", function (ev) {
|
||||
snippetEdit.saveButton.addEventListener("click", function(ev) {
|
||||
let projectsChanged = false;
|
||||
let projInputs = snippetEdit.projectList.querySelectorAll("input[name=project_id]");
|
||||
let assignedIds = [];
|
||||
|
@ -354,8 +349,8 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
cancel();
|
||||
}
|
||||
});
|
||||
|
||||
snippetEdit.deleteButton.addEventListener("click", function (ev) {
|
||||
|
||||
snippetEdit.deleteButton.addEventListener("click", function(ev) {
|
||||
if (!window.confirm("Are you sure you want to delete this snippet?")) {
|
||||
ev.preventDefault();
|
||||
return;
|
||||
|
@ -372,10 +367,10 @@ function makeSnippetEdit(ownerName, ownerAvatar, ownerUrl, date, text, attachmen
|
|||
function editTimelineSnippet(timelineItemEl, stickyProjectId) {
|
||||
let ownerName = timelineItemEl.querySelector(".user")?.textContent;
|
||||
let ownerUrl = timelineItemEl.querySelector(".user")?.href;
|
||||
let ownerAvatar = timelineItemEl.querySelector(".avatar")?.src;
|
||||
let ownerAvatar = timelineItemEl.querySelector(".avatar-icon")?.src;
|
||||
let creationDate = new Date(timelineItemEl.querySelector("time").dateTime);
|
||||
let rawDesc = timelineItemEl.querySelector(".rawdesc").textContent;
|
||||
let attachment = timelineItemEl.querySelector(".timeline-media")?.children?.[0];
|
||||
let attachment = timelineItemEl.querySelector(".timeline-content-box")?.children?.[0];
|
||||
let projectIds = [];
|
||||
let projectEls = timelineItemEl.querySelectorAll(".projects > a");
|
||||
for (let i = 0; i < projectEls.length; ++i) {
|
||||
|
|
|
@ -1,38 +1,106 @@
|
|||
function initTabs(container, {
|
||||
initialTab = null,
|
||||
onSelect = (name) => {},
|
||||
}) {
|
||||
const buttons = Array.from(container.querySelectorAll("[data-tab-button]"));
|
||||
const tabs = Array.from(container.querySelectorAll("[data-tab]"));
|
||||
function TabState(tabbed) {
|
||||
this.container = tabbed;
|
||||
this.tabs = tabbed.querySelector(".tab");
|
||||
|
||||
const firstTab = tabs[0].getAttribute("data-tab");
|
||||
this.tabbar = document.createElement("div");
|
||||
this.tabbar.classList.add("tab-bar");
|
||||
this.container.insertBefore(this.tabbar, this.container.firstChild);
|
||||
|
||||
function selectTab(name, { sendEvent = true } = {}) {
|
||||
if (!document.querySelector(`[data-tab="${name}"]`)) {
|
||||
console.warn("no tab found with name", name);
|
||||
return selectTab(firstTab, initial);
|
||||
this.current_i = -1;
|
||||
this.tab_buttons = [];
|
||||
}
|
||||
|
||||
function switch_tab_old(state, tab_i) {
|
||||
return function() {
|
||||
if (state.current_i >= 0) {
|
||||
state.tabs[state.current_i].classList.add("hidden");
|
||||
state.tab_buttons[state.current_i].classList.remove("current");
|
||||
}
|
||||
|
||||
for (const tab of tabs) {
|
||||
tab.hidden = tab.getAttribute("data-tab") !== name;
|
||||
}
|
||||
for (const button of buttons) {
|
||||
button.classList.toggle("tab-button-active", button.getAttribute("data-tab-button") === name);
|
||||
}
|
||||
state.tabs[tab_i].classList.remove("hidden");
|
||||
state.tab_buttons[tab_i].classList.add("current");
|
||||
|
||||
if (sendEvent) {
|
||||
onSelect(name);
|
||||
var hash = "";
|
||||
if (state.tabs[tab_i].hasAttribute("data-url-hash")) {
|
||||
hash = state.tabs[tab_i].getAttribute("data-url-hash");
|
||||
}
|
||||
}
|
||||
selectTab(initialTab || firstTab, { sendEvent: false });
|
||||
window.location.hash = hash;
|
||||
|
||||
for (const button of buttons) {
|
||||
button.addEventListener("click", () => {
|
||||
selectTab(button.getAttribute("data-tab-button"));
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
selectTab,
|
||||
state.current_i = tab_i;
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
const tabContainers = document.getElementsByClassName("tabbed");
|
||||
for (const container of tabContainers) {
|
||||
const tabBar = document.createElement("div");
|
||||
tabBar.classList.add("tab-bar");
|
||||
container.insertAdjacentElement('afterbegin', tabBar);
|
||||
|
||||
const tabs = container.querySelectorAll(".tab");
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
const tab = tabs[i];
|
||||
tab.classList.toggle('dn', i > 0);
|
||||
|
||||
const slug = tab.getAttribute("data-slug");
|
||||
|
||||
// TODO: Should this element be a link?
|
||||
const tabButton = document.createElement("div");
|
||||
tabButton.classList.add("tab-button");
|
||||
tabButton.classList.toggle("current", i === 0);
|
||||
tabButton.innerText = tab.getAttribute("data-name");
|
||||
tabButton.setAttribute("data-slug", slug);
|
||||
|
||||
tabButton.addEventListener("click", () => {
|
||||
switchTab(container, slug);
|
||||
});
|
||||
|
||||
tabBar.appendChild(tabButton);
|
||||
}
|
||||
|
||||
const initialSlug = window.location.hash;
|
||||
if (initialSlug) {
|
||||
switchTab(container, initialSlug.substring(1));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function switchTab(container, slug) {
|
||||
const tabs = container.querySelectorAll('.tab');
|
||||
|
||||
let didMatch = false;
|
||||
for (const tab of tabs) {
|
||||
const slugMatches = tab.getAttribute("data-slug") === slug;
|
||||
tab.classList.toggle('dn', !slugMatches);
|
||||
|
||||
if (slugMatches) {
|
||||
didMatch = true;
|
||||
}
|
||||
}
|
||||
|
||||
const tabButtons = document.querySelectorAll(".tab-button");
|
||||
for (const tabButton of tabButtons) {
|
||||
const buttonSlug = tabButton.getAttribute("data-slug");
|
||||
tabButton.classList.toggle('current', slug === buttonSlug);
|
||||
}
|
||||
|
||||
if (!didMatch) {
|
||||
// switch to first tab as a fallback
|
||||
tabs[0].classList.remove('dn');
|
||||
tabButtons[0].classList.add('current');
|
||||
}
|
||||
|
||||
window.location.hash = slug;
|
||||
}
|
||||
|
||||
function switchToTabOfElement(container, el) {
|
||||
const tabs = Array.from(container.querySelectorAll('.tab'));
|
||||
let target = el.parentElement;
|
||||
while (target) {
|
||||
if (tabs.includes(target)) {
|
||||
switchTab(container, target.getAttribute("data-slug"));
|
||||
return;
|
||||
}
|
||||
target = target.parentElement;
|
||||
}
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 228 KiB |
Before Width: | Height: | Size: 267 KiB |
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 236 253" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="books" x="0" y="0" width="235.74" height="252.97" style="fill:none;"/><path d="M77.61,252.704l0,-230.569l80.52,-0l-0,230.569l-80.52,0Zm59.686,-188.853l0,-20.883l-38.853,-0l0,20.883l38.853,0Zm0,128.926l0,-108.092l-38.853,-0l0,108.092l38.853,-0Zm-38.853,20.833l0,18.261l38.853,-0l0,-18.261l-38.853,0Zm-98.032,39.094l-0,-252.438l70.429,-0l-0,252.438l-70.429,0Zm49.596,-206.372l-0,-25.233l-28.763,-0l0,25.233l28.763,0Zm-0,142.369l-0,-121.536l-28.763,0l0,121.536l28.763,-0Zm-28.763,20.833l0,22.337c0,-0 28.763,-0 28.763,-0l-0,-22.337l-28.763,-0Zm143.656,43.17l-0,-252.438l70.429,-0l-0,252.438l-70.429,0Zm49.596,-206.372l-0,-25.233l-28.763,-0l0,25.233l28.763,0Zm-0,142.369l-0,-121.536l-28.763,0l0,121.536l28.763,-0Zm-28.763,20.833l0,22.337l28.763,-0l-0,-22.337l-28.763,-0Z"/></svg>
|
Before Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.8 KiB |
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 336 233" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="lightbulb" x="0" y="0" width="335.159" height="232.656" style="fill:none;"/><path d="M197.613,202.015c0,16.576 -13.457,30.034 -30.034,30.034c-16.576,-0 -30.034,-13.458 -30.034,-30.034l60.068,-0Zm0.06,-9.97l-60.206,-0c-3.155,-16.04 -5.152,-23.793 -32.39,-57.373c-10.513,-12.96 -16.178,-29.812 -16.178,-47.773c0,-43.425 35.256,-78.68 78.68,-78.68c43.425,-0 78.68,34.8 78.68,78.224c0,17.961 -5.665,34.814 -16.177,47.774c-27.238,33.58 -29.254,41.788 -32.409,57.828Zm-16.343,-20.834c3.88,-11.276 11.569,-24.225 32.572,-50.118c7.611,-9.383 11.524,-21.647 11.524,-34.65c-0,-31.893 -25.954,-57.391 -57.847,-57.391c-31.926,0 -57.846,25.92 -57.846,57.847c-0,13.003 3.913,25.266 11.524,34.649c20.946,25.823 28.639,38.556 32.521,49.663l27.552,0Zm-160.921,-151.684c-4.694,-3.321 -5.808,-9.827 -2.488,-14.52c3.321,-4.693 9.827,-5.808 14.52,-2.488l31.239,22.101c4.694,3.321 5.808,9.827 2.488,14.52c-3.32,4.693 -9.827,5.808 -14.52,2.488l-31.239,-22.101Zm12.032,153.97c-4.693,3.32 -11.199,2.205 -14.52,-2.488c-3.32,-4.693 -2.206,-11.199 2.488,-14.52l31.239,-22.101c4.693,-3.32 11.2,-2.205 14.52,2.488c3.32,4.693 2.206,11.199 -2.488,14.52l-31.239,22.101Zm-21.856,-74.77c-5.749,-0 -10.416,-4.668 -10.416,-10.417c-0,-5.749 4.667,-10.417 10.416,-10.417l38.267,0c5.749,0 10.417,4.668 10.417,10.417c-0,5.749 -4.668,10.417 -10.417,10.417l-38.267,-0Zm292.133,-95.906c4.693,-3.32 11.199,-2.205 14.519,2.488c3.321,4.693 2.206,11.199 -2.487,14.52l-31.239,22.101c-4.694,3.32 -11.2,2.206 -14.52,-2.488c-3.321,-4.693 -2.206,-11.199 2.487,-14.52l31.24,-22.101Zm12.032,153.97c4.693,3.321 5.808,9.827 2.487,14.52c-3.32,4.694 -9.826,5.808 -14.519,2.488l-31.24,-22.101c-4.693,-3.321 -5.808,-9.827 -2.487,-14.52c3.32,-4.693 9.826,-5.808 14.52,-2.488l31.239,22.101Zm9.823,-78.595c5.75,-0 10.417,4.667 10.417,10.416c0,5.749 -4.667,10.417 -10.417,10.417l-38.266,-0c-5.75,-0 -10.417,-4.668 -10.417,-10.417c-0,-5.749 4.667,-10.416 10.417,-10.416l38.266,-0Z"/></svg>
|
Before Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 12 KiB |
|
@ -1 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 301 229" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><rect id="presentation" x="-0" y="0" width="300.716" height="228.578" style="fill:none;"/><path d="M124.979,14.95l175.159,0l0,152.429l-137.896,0c-1.49,-7.262 -3.898,-14.19 -7.097,-20.659l124.334,-0l-0,-111.11l-143.514,-0c-2.302,-7.632 -6.074,-14.628 -10.986,-20.66Zm-119.77,196.191c-2.997,-8.252 -4.631,-17.155 -4.631,-26.437c0,-42.797 34.745,-77.542 77.541,-77.542c42.796,0 77.541,34.745 77.541,77.542c0,9.282 -1.634,18.185 -4.631,26.437c-22.091,10.548 -46.816,16.455 -72.91,16.455c-26.094,0 -50.819,-5.907 -72.91,-16.455Zm15.522,-13.612c17.693,7.296 37.075,11.317 57.388,11.317c20.314,0 39.695,-4.021 57.389,-11.317c0.919,-4.129 1.402,-8.421 1.402,-12.825c0,-32.448 -26.343,-58.792 -58.791,-58.792c-32.448,0 -58.791,26.344 -58.791,58.792c0,4.404 0.484,8.696 1.403,12.825Zm57.388,-196.547c28.741,-0 52.075,23.334 52.075,52.074c-0,28.741 -23.334,52.075 -52.075,52.075c-28.74,-0 -52.074,-23.334 -52.074,-52.075c-0,-28.74 23.334,-52.074 52.074,-52.074Zm0,18.75c-18.392,-0 -33.324,14.932 -33.324,33.324c-0,18.392 14.932,33.325 33.324,33.325c18.392,-0 33.325,-14.933 33.325,-33.325c-0,-18.392 -14.933,-33.324 -33.325,-33.324Z"/></svg>
|
Before Width: | Height: | Size: 1.5 KiB |
16875
public/style.css
|
@ -0,0 +1,309 @@
|
|||
/*
|
||||
Inserts a CSS expression with one or more custom variables.
|
||||
You can provide an arbitrary number of strings in the second
|
||||
argument, separated by spaces. Any strings corresponding to
|
||||
variable names will be replaced by the correct values, while
|
||||
other strings are left untouched.
|
||||
|
||||
Example usage:
|
||||
|
||||
@include usevar(border-color, dimmer-color);
|
||||
@include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")");
|
||||
|
||||
For clarity and to avoid syntax issues, you are encouraged to
|
||||
use unquoted strings for variables and quoted strings for
|
||||
everything else.
|
||||
|
||||
For convenience in common cases, if only a single argument
|
||||
is provided and it does not match an existing variable, this
|
||||
will throw an error.
|
||||
*/
|
||||
pre, code, .codeblock {
|
||||
/* Comment */
|
||||
/* Error */
|
||||
/* Keyword */
|
||||
/* Literal */
|
||||
/* Name */
|
||||
/* Operator */
|
||||
/* Punctuation */
|
||||
/* Comment.Multiline */
|
||||
/* Comment.Preproc */
|
||||
/* Comment.Single */
|
||||
/* Comment.Special */
|
||||
/* Generic.Emph */
|
||||
/* Generic.Strong */
|
||||
/* Keyword.Constant */
|
||||
/* Keyword.Declaration */
|
||||
/* Keyword.Namespace */
|
||||
/* Keyword.Pseudo */
|
||||
/* Keyword.Reserved */
|
||||
/* Keyword.Type */
|
||||
/* Literal.Date */
|
||||
/* Literal.Number */
|
||||
/* Literal.String */
|
||||
/* Name.Attribute */
|
||||
/* Name.Builtin */
|
||||
/* Name.Class */
|
||||
/* Name.Constant */
|
||||
/* Name.Decorator */
|
||||
/* Name.Entity */
|
||||
/* Name.Exception */
|
||||
/* Name.Function */
|
||||
/* Name.Label */
|
||||
/* Name.Namespace */
|
||||
/* Name.Other */
|
||||
/* Name.Property */
|
||||
/* Name.Tag */
|
||||
/* Name.Variable */
|
||||
/* Operator.Word */
|
||||
/* Text.Whitespace */
|
||||
/* Literal.Number.Float */
|
||||
/* Literal.Number.Hex */
|
||||
/* Literal.Number.Integer */
|
||||
/* Literal.Number.Oct */
|
||||
/* Literal.String.Backtick */
|
||||
/* Literal.String.Char */
|
||||
/* Literal.String.Doc */
|
||||
/* Literal.String.Double */
|
||||
/* Literal.String.Escape */
|
||||
/* Literal.String.Heredoc */
|
||||
/* Literal.String.Interpol */
|
||||
/* Literal.String.Other */
|
||||
/* Literal.String.Regex */
|
||||
/* Literal.String.Single */
|
||||
/* Literal.String.Symbol */
|
||||
/* Name.Builtin.Pseudo */
|
||||
/* Name.Variable.Class */
|
||||
/* Name.Variable.Global */
|
||||
/* Name.Variable.Instance */
|
||||
/* Literal.Number.Integer.Long */
|
||||
/* Generic Heading & Diff Header */
|
||||
/* Generic.Subheading & Diff Unified/Comment? */
|
||||
/* Generic.Deleted & Diff Deleted */
|
||||
/* Generic.Inserted & Diff Inserted */ }
|
||||
pre .hll, code .hll, .codeblock .hll {
|
||||
background-color: #49483e; }
|
||||
pre .c, code .c, .codeblock .c {
|
||||
color: #75715e; }
|
||||
pre .err, code .err, .codeblock .err {
|
||||
color: #ff0000; }
|
||||
pre .k, code .k, .codeblock .k {
|
||||
color: #66d9ef; }
|
||||
pre .l, code .l, .codeblock .l {
|
||||
color: #ae81ff; }
|
||||
pre .n, code .n, .codeblock .n {
|
||||
color: #f8f8f2; }
|
||||
pre .o, code .o, .codeblock .o {
|
||||
color: #f92672; }
|
||||
pre .p, code .p, .codeblock .p {
|
||||
color: #f8f8f2; }
|
||||
pre .cm, code .cm, .codeblock .cm {
|
||||
color: #75715e; }
|
||||
pre .cp, code .cp, .codeblock .cp {
|
||||
color: #75715e; }
|
||||
pre .c1, code .c1, .codeblock .c1 {
|
||||
color: #75715e; }
|
||||
pre .cs, code .cs, .codeblock .cs {
|
||||
color: #75715e; }
|
||||
pre .ge, code .ge, .codeblock .ge {
|
||||
font-style: italic; }
|
||||
pre .gs, code .gs, .codeblock .gs {
|
||||
font-weight: bold; }
|
||||
pre .kc, code .kc, .codeblock .kc {
|
||||
color: #66d9ef; }
|
||||
pre .kd, code .kd, .codeblock .kd {
|
||||
color: #66d9ef; }
|
||||
pre .kn, code .kn, .codeblock .kn {
|
||||
color: #f92672; }
|
||||
pre .kp, code .kp, .codeblock .kp {
|
||||
color: #66d9ef; }
|
||||
pre .kr, code .kr, .codeblock .kr {
|
||||
color: #66d9ef; }
|
||||
pre .kt, code .kt, .codeblock .kt {
|
||||
color: #66d9ef; }
|
||||
pre .ld, code .ld, .codeblock .ld {
|
||||
color: #e6db74; }
|
||||
pre .m, code .m, .codeblock .m {
|
||||
color: #ae81ff; }
|
||||
pre .s, code .s, .codeblock .s {
|
||||
color: #e6db74; }
|
||||
pre .na, code .na, .codeblock .na {
|
||||
color: #a6e22e; }
|
||||
pre .nb, code .nb, .codeblock .nb {
|
||||
color: #f8f8f2; }
|
||||
pre .nc, code .nc, .codeblock .nc {
|
||||
color: #a6e22e; }
|
||||
pre .no, code .no, .codeblock .no {
|
||||
color: #66d9ef; }
|
||||
pre .nd, code .nd, .codeblock .nd {
|
||||
color: #a6e22e; }
|
||||
pre .ni, code .ni, .codeblock .ni {
|
||||
color: #f8f8f2; }
|
||||
pre .ne, code .ne, .codeblock .ne {
|
||||
color: #a6e22e; }
|
||||
pre .nf, code .nf, .codeblock .nf {
|
||||
color: #a6e22e; }
|
||||
pre .nl, code .nl, .codeblock .nl {
|
||||
color: #f8f8f2; }
|
||||
pre .nn, code .nn, .codeblock .nn {
|
||||
color: #f8f8f2; }
|
||||
pre .nx, code .nx, .codeblock .nx {
|
||||
color: #a6e22e; }
|
||||
pre .py, code .py, .codeblock .py {
|
||||
color: #f8f8f2; }
|
||||
pre .nt, code .nt, .codeblock .nt {
|
||||
color: #f92672; }
|
||||
pre .nv, code .nv, .codeblock .nv {
|
||||
color: #f8f8f2; }
|
||||
pre .ow, code .ow, .codeblock .ow {
|
||||
color: #f92672; }
|
||||
pre .w, code .w, .codeblock .w {
|
||||
color: #f8f8f2; }
|
||||
pre .mf, code .mf, .codeblock .mf {
|
||||
color: #ae81ff; }
|
||||
pre .mh, code .mh, .codeblock .mh {
|
||||
color: #ae81ff; }
|
||||
pre .mi, code .mi, .codeblock .mi {
|
||||
color: #ae81ff; }
|
||||
pre .mo, code .mo, .codeblock .mo {
|
||||
color: #ae81ff; }
|
||||
pre .sb, code .sb, .codeblock .sb {
|
||||
color: #e6db74; }
|
||||
pre .sc, code .sc, .codeblock .sc {
|
||||
color: #e6db74; }
|
||||
pre .sd, code .sd, .codeblock .sd {
|
||||
color: #e6db74; }
|
||||
pre .s2, code .s2, .codeblock .s2 {
|
||||
color: #e6db74; }
|
||||
pre .se, code .se, .codeblock .se {
|
||||
color: #ae81ff; }
|
||||
pre .sh, code .sh, .codeblock .sh {
|
||||
color: #e6db74; }
|
||||
pre .si, code .si, .codeblock .si {
|
||||
color: #e6db74; }
|
||||
pre .sx, code .sx, .codeblock .sx {
|
||||
color: #e6db74; }
|
||||
pre .sr, code .sr, .codeblock .sr {
|
||||
color: #e6db74; }
|
||||
pre .s1, code .s1, .codeblock .s1 {
|
||||
color: #e6db74; }
|
||||
pre .ss, code .ss, .codeblock .ss {
|
||||
color: #e6db74; }
|
||||
pre .bp, code .bp, .codeblock .bp {
|
||||
color: #f8f8f2; }
|
||||
pre .vc, code .vc, .codeblock .vc {
|
||||
color: #f8f8f2; }
|
||||
pre .vg, code .vg, .codeblock .vg {
|
||||
color: #f8f8f2; }
|
||||
pre .vi, code .vi, .codeblock .vi {
|
||||
color: #f8f8f2; }
|
||||
pre .il, code .il, .codeblock .il {
|
||||
color: #ae81ff; }
|
||||
pre .gu, code .gu, .codeblock .gu {
|
||||
color: #75715e; }
|
||||
pre .gd, code .gd, .codeblock .gd {
|
||||
color: #f92672; }
|
||||
pre .gi, code .gi, .codeblock .gi {
|
||||
color: #a6e22e; }
|
||||
|
||||
.light {
|
||||
background-color: #fff;
|
||||
color: #000; }
|
||||
|
||||
:root {
|
||||
--fg-font-color: #eee;
|
||||
--theme-color: #666;
|
||||
--theme-color-dim: #444;
|
||||
--theme-color-dimmer: #383838;
|
||||
--theme-color-dimmest: #333;
|
||||
--theme-color-dark: #666;
|
||||
--theme-color-light: #666;
|
||||
--link-color: #aaa;
|
||||
--link-border-color: #aaa;
|
||||
--hr-color: #aaa;
|
||||
--main-background-color: #202020;
|
||||
--main-color: #eee;
|
||||
--dim-color: #bbb;
|
||||
--dimmer-color: #999;
|
||||
--dimmest-color: #777;
|
||||
--menu-bottom-border-color: #444;
|
||||
--login-popup-background: #181818;
|
||||
--content-background: #202020;
|
||||
--content-background-transparent: rgba(32, 32, 32, 0);
|
||||
--dim-background: #252525;
|
||||
--dim-background-transparent: rgba(37, 37, 37, 0);
|
||||
--text-background: #181818;
|
||||
--spoiler-border: #777;
|
||||
--background-even-background: #242424;
|
||||
--project-card-border-color: #333;
|
||||
--project-user-suggestions-background: #222;
|
||||
--project-user-suggestions-border-color: #444;
|
||||
--notice-text-color: #eee;
|
||||
--notice-unapproved-color: #7a2020;
|
||||
--notice-hidden-color: #494949;
|
||||
--notice-hiatus-color: #876327;
|
||||
--notice-dead-color: #7a2020;
|
||||
--notice-lts-color: #2a681d;
|
||||
--notice-lts-reqd-color: #876327;
|
||||
--notice-success-color: #2a681d;
|
||||
--notice-warn-color: #876327;
|
||||
--notice-failure-color: #7a2020;
|
||||
--optionbar-border-color: #333;
|
||||
--tab-background: #181818;
|
||||
--tab-border-color: #3f3f3f;
|
||||
--tab-button-background: #303030;
|
||||
--tab-button-background-hover: #383838;
|
||||
--tab-button-background-current: #181818;
|
||||
--form-check-background: #252527;
|
||||
--form-check-border-color: #666;
|
||||
--form-check-border-color-hover: #084068;
|
||||
--form-text-background: #181818;
|
||||
--form-text-background-active: #252527;
|
||||
--form-text-border-color: #444;
|
||||
--form-text-border-color-active: #084068;
|
||||
--form-button-color: #999;
|
||||
--form-button-color-active: #4c9ed9;
|
||||
--form-button-background: #383838;
|
||||
--form-button-background-active: #303840;
|
||||
--form-button-border-color: transparent;
|
||||
--form-button-inline-border-color: transparent;
|
||||
--form-error-color: #c61d24;
|
||||
--landing-search-background: #282828;
|
||||
--landing-search-background-hover: #181818;
|
||||
--editor-toolbar-background: #282828;
|
||||
--editor-toolbar-border-color: #333;
|
||||
--editor-toolbar-button-background: #282828;
|
||||
--editor-toolbar-button-background-hover: #333;
|
||||
--editor-toolbar-button-border-color: #333;
|
||||
--post-blockquote-border-color: #555;
|
||||
--forum-even-background: #242424;
|
||||
--forum-thread-read-color: #777;
|
||||
--forum-thread-read-link-color: #999;
|
||||
--forum-post-author-color: #999;
|
||||
--forum-diff-source-background: #181818;
|
||||
--forum-diff-source-border-color: #444;
|
||||
--forum-diff-replace-background: #18283a;
|
||||
--forum-diff-replace-border-color: #223d5b;
|
||||
--forum-diff-delete-background: #3a1818;
|
||||
--forum-diff-delete-border-color: #6b1e1c;
|
||||
--forum-diff-insert-background: #233a18;
|
||||
--forum-diff-insert-border-color: #30591b;
|
||||
--card-background: #282828;
|
||||
--card-background-hover: #333;
|
||||
--timeline-content-background: rgba(255, 255, 255, 0.06);
|
||||
--irc-border-color: #333;
|
||||
--irc-tab-current-shadow: 0px 0px 5px #000 inset;
|
||||
--irc-tab-close-button-color: #bbb;
|
||||
--irc-tab-close-button-background: #444;
|
||||
--irc-nick-border-color: #444;
|
||||
--irc-users-color: #aaa;
|
||||
--irc-users-background: #181818;
|
||||
--irc-users-border-color: transparent;
|
||||
--irc-users-popout-background: #181818;
|
||||
--irc-users-popout-border-color-left: #444;
|
||||
--irc-users-popout-border-color-right: #333;
|
||||
--code-line-number-color: #444;
|
||||
--library-star-btn-background: #252525;
|
||||
--library-star-btn-border-color: #bbb;
|
||||
--library-star-btn-a-border-color: #999;
|
||||
--library-star-btn-a-hover-background: #333; }
|
|
@ -0,0 +1,327 @@
|
|||
/*
|
||||
Inserts a CSS expression with one or more custom variables.
|
||||
You can provide an arbitrary number of strings in the second
|
||||
argument, separated by spaces. Any strings corresponding to
|
||||
variable names will be replaced by the correct values, while
|
||||
other strings are left untouched.
|
||||
|
||||
Example usage:
|
||||
|
||||
@include usevar(border-color, dimmer-color);
|
||||
@include usevar(background, "linear-gradient(" dim-background-transparent "," dim-background ")");
|
||||
|
||||
For clarity and to avoid syntax issues, you are encouraged to
|
||||
use unquoted strings for variables and quoted strings for
|
||||
everything else.
|
||||
|
||||
For convenience in common cases, if only a single argument
|
||||
is provided and it does not match an existing variable, this
|
||||
will throw an error.
|
||||
*/
|
||||
pre, code, .codeblock {
|
||||
/* Comment */
|
||||
/* Error */
|
||||
/* Keyword */
|
||||
/* Operator */
|
||||
/* Comment.Multiline */
|
||||
/* Comment.Preproc */
|
||||
/* Comment.Single */
|
||||
/* Comment.Special */
|
||||
/* Generic.Deleted */
|
||||
/* Generic.Emph */
|
||||
/* Generic.Error */
|
||||
/* Generic.Heading */
|
||||
/* Generic.Inserted */
|
||||
/* Generic.Output */
|
||||
/* Generic.Prompt */
|
||||
/* Generic.Strong */
|
||||
/* Generic.Subheading */
|
||||
/* Generic.Traceback */
|
||||
/* Keyword.Constant */
|
||||
/* Keyword.Declaration */
|
||||
/* Keyword.Namespace */
|
||||
/* Keyword.Pseudo */
|
||||
/* Keyword.Reserved */
|
||||
/* Keyword.Type */
|
||||
/* Literal.Number */
|
||||
/* Literal.String */
|
||||
/* Name.Attribute */
|
||||
/* Name.Builtin */
|
||||
/* Name.Class */
|
||||
/* Name.Constant */
|
||||
/* Name.Decorator */
|
||||
/* Name.Entity */
|
||||
/* Name.Exception */
|
||||
/* Name.Function */
|
||||
/* Name.Label */
|
||||
/* Name.Namespace */
|
||||
/* Name.Tag */
|
||||
/* Name.Variable */
|
||||
/* Operator.Word */
|
||||
/* Text.Whitespace */
|
||||
/* Literal.Number.Float */
|
||||
/* Literal.Number.Hex */
|
||||
/* Literal.Number.Integer */
|
||||
/* Literal.Number.Oct */
|
||||
/* Literal.String.Backtick */
|
||||
/* Literal.String.Char */
|
||||
/* Literal.String.Doc */
|
||||
/* Literal.String.Double */
|
||||
/* Literal.String.Escape */
|
||||
/* Literal.String.Heredoc */
|
||||
/* Literal.String.Interpol */
|
||||
/* Literal.String.Other */
|
||||
/* Literal.String.Regex */
|
||||
/* Literal.String.Single */
|
||||
/* Literal.String.Symbol */
|
||||
/* Name.Builtin.Pseudo */
|
||||
/* Name.Variable.Class */
|
||||
/* Name.Variable.Global */
|
||||
/* Name.Variable.Instance */
|
||||
/* Literal.Number.Integer.Long */ }
|
||||
pre .hll, code .hll, .codeblock .hll {
|
||||
background-color: #ffffcc; }
|
||||
pre .c, code .c, .codeblock .c {
|
||||
color: #60a0b0;
|
||||
font-style: italic; }
|
||||
pre .err, code .err, .codeblock .err {
|
||||
color: #FF0000; }
|
||||
pre .k, code .k, .codeblock .k {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .o, code .o, .codeblock .o {
|
||||
color: #666666; }
|
||||
pre .cm, code .cm, .codeblock .cm {
|
||||
color: #60a0b0;
|
||||
font-style: italic; }
|
||||
pre .cp, code .cp, .codeblock .cp {
|
||||
color: #007020; }
|
||||
pre .c1, code .c1, .codeblock .c1 {
|
||||
color: #60a0b0;
|
||||
font-style: italic; }
|
||||
pre .cs, code .cs, .codeblock .cs {
|
||||
color: #60a0b0;
|
||||
background-color: #fff0f0; }
|
||||
pre .gd, code .gd, .codeblock .gd {
|
||||
color: #A00000; }
|
||||
pre .ge, code .ge, .codeblock .ge {
|
||||
font-style: italic; }
|
||||
pre .gr, code .gr, .codeblock .gr {
|
||||
color: #FF0000; }
|
||||
pre .gh, code .gh, .codeblock .gh {
|
||||
color: #000080;
|
||||
font-weight: bold; }
|
||||
pre .gi, code .gi, .codeblock .gi {
|
||||
color: #00A000; }
|
||||
pre .go, code .go, .codeblock .go {
|
||||
color: #808080; }
|
||||
pre .gp, code .gp, .codeblock .gp {
|
||||
color: #c65d09;
|
||||
font-weight: bold; }
|
||||
pre .gs, code .gs, .codeblock .gs {
|
||||
font-weight: bold; }
|
||||
pre .gu, code .gu, .codeblock .gu {
|
||||
color: #800080;
|
||||
font-weight: bold; }
|
||||
pre .gt, code .gt, .codeblock .gt {
|
||||
color: #0040D0; }
|
||||
pre .kc, code .kc, .codeblock .kc {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .kd, code .kd, .codeblock .kd {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .kn, code .kn, .codeblock .kn {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .kp, code .kp, .codeblock .kp {
|
||||
color: #007020; }
|
||||
pre .kr, code .kr, .codeblock .kr {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .kt, code .kt, .codeblock .kt {
|
||||
color: #902000; }
|
||||
pre .m, code .m, .codeblock .m {
|
||||
color: #40a070; }
|
||||
pre .s, code .s, .codeblock .s {
|
||||
color: #4070a0; }
|
||||
pre .na, code .na, .codeblock .na {
|
||||
color: #4070a0; }
|
||||
pre .nb, code .nb, .codeblock .nb {
|
||||
color: #007020; }
|
||||
pre .nc, code .nc, .codeblock .nc {
|
||||
color: #0e84b5;
|
||||
font-weight: bold; }
|
||||
pre .no, code .no, .codeblock .no {
|
||||
color: #60add5; }
|
||||
pre .nd, code .nd, .codeblock .nd {
|
||||
color: #555555;
|
||||
font-weight: bold; }
|
||||
pre .ni, code .ni, .codeblock .ni {
|
||||
color: #d55537;
|
||||
font-weight: bold; }
|
||||
pre .ne, code .ne, .codeblock .ne {
|
||||
color: #007020; }
|
||||
pre .nf, code .nf, .codeblock .nf {
|
||||
color: #06287e; }
|
||||
pre .nl, code .nl, .codeblock .nl {
|
||||
color: #002070;
|
||||
font-weight: bold; }
|
||||
pre .nn, code .nn, .codeblock .nn {
|
||||
color: #0e84b5;
|
||||
font-weight: bold; }
|
||||
pre .nt, code .nt, .codeblock .nt {
|
||||
color: #062873;
|
||||
font-weight: bold; }
|
||||
pre .nv, code .nv, .codeblock .nv {
|
||||
color: #bb60d5; }
|
||||
pre .ow, code .ow, .codeblock .ow {
|
||||
color: #007020;
|
||||
font-weight: bold; }
|
||||
pre .w, code .w, .codeblock .w {
|
||||
color: #bbbbbb; }
|
||||
pre .mf, code .mf, .codeblock .mf {
|
||||
color: #40a070; }
|
||||
pre .mh, code .mh, .codeblock .mh {
|
||||
color: #40a070; }
|
||||
pre .mi, code .mi, .codeblock .mi {
|
||||
color: #40a070; }
|
||||
pre .mo, code .mo, .codeblock .mo {
|
||||
color: #40a070; }
|
||||
pre .sb, code .sb, .codeblock .sb {
|
||||
color: #4070a0; }
|
||||
pre .sc, code .sc, .codeblock .sc {
|
||||
color: #4070a0; }
|
||||
pre .sd, code .sd, .codeblock .sd {
|
||||
color: #4070a0;
|
||||
font-style: italic; }
|
||||
pre .s2, code .s2, .codeblock .s2 {
|
||||
color: #4070a0; }
|
||||
pre .se, code .se, .codeblock .se {
|
||||
color: #4070a0;
|
||||
font-weight: bold; }
|
||||
pre .sh, code .sh, .codeblock .sh {
|
||||
color: #4070a0; }
|
||||
pre .si, code .si, .codeblock .si {
|
||||
color: #70a0d0;
|
||||
font-style: italic; }
|
||||
pre .sx, code .sx, .codeblock .sx {
|
||||
color: #c65d09; }
|
||||
pre .sr, code .sr, .codeblock .sr {
|
||||
color: #235388; }
|
||||
pre .s1, code .s1, .codeblock .s1 {
|
||||
color: #4070a0; }
|
||||
pre .ss, code .ss, .codeblock .ss {
|
||||
color: #517918; }
|
||||
pre .bp, code .bp, .codeblock .bp {
|
||||
color: #007020; }
|
||||
pre .vc, code .vc, .codeblock .vc {
|
||||
color: #bb60d5; }
|
||||
pre .vg, code .vg, .codeblock .vg {
|
||||
color: #bb60d5; }
|
||||
pre .vi, code .vi, .codeblock .vi {
|
||||
color: #bb60d5; }
|
||||
pre .il, code .il, .codeblock .il {
|
||||
color: #40a070; }
|
||||
|
||||
.dark {
|
||||
background-color: #222;
|
||||
color: #bbb; }
|
||||
|
||||
:root {
|
||||
--fg-font-color: black;
|
||||
--theme-color: #666;
|
||||
--theme-color-dim: #aaa;
|
||||
--theme-color-dimmer: #bbb;
|
||||
--theme-color-dimmest: #ccc;
|
||||
--theme-color-dark: #666;
|
||||
--theme-color-light: #666;
|
||||
--link-color: #666;
|
||||
--link-border-color: #666;
|
||||
--hr-color: #444;
|
||||
--main-background-color: #fff;
|
||||
--main-color: black;
|
||||
--dim-color: #333;
|
||||
--dimmer-color: #999;
|
||||
--dimmest-color: #bbb;
|
||||
--menu-bottom-border-color: black;
|
||||
--login-popup-background: #fbfbfb;
|
||||
--content-background: #f8f8f8;
|
||||
--content-background-transparent: rgba(248, 248, 248, 0);
|
||||
--dim-background: #f0f0f0;
|
||||
--dim-background-transparent: rgba(240, 240, 240, 0);
|
||||
--text-background: #f9f9f9;
|
||||
--spoiler-border: #aaa;
|
||||
--background-even-background: #f8f8f8;
|
||||
--project-card-border-color: #aaa;
|
||||
--project-user-suggestions-background: #fff;
|
||||
--project-user-suggestions-border-color: #ddd;
|
||||
--notice-text-color: #fff;
|
||||
--notice-unapproved-color: #b42222;
|
||||
--notice-hidden-color: #b6b6b6;
|
||||
--notice-hiatus-color: #aa7d30;
|
||||
--notice-dead-color: #b42222;
|
||||
--notice-lts-color: #43a52f;
|
||||
--notice-lts-reqd-color: #aa7d30;
|
||||
--notice-success-color: #43a52f;
|
||||
--notice-warn-color: #aa7d30;
|
||||
--notice-failure-color: #b42222;
|
||||
--optionbar-border-color: #ccc;
|
||||
--tab-background: #fff;
|
||||
--tab-border-color: #d8d8d8;
|
||||
--tab-button-background: #dfdfdf;
|
||||
--tab-button-background-hover: #efefef;
|
||||
--tab-button-background-current: #fff;
|
||||
--form-check-background: #fafafc;
|
||||
--form-check-border-color: #999;
|
||||
--form-check-border-color-hover: #4c9ed9;
|
||||
--form-text-background: #fff;
|
||||
--form-text-background-active: #fafafc;
|
||||
--form-text-border-color: #999;
|
||||
--form-text-border-color-active: #4c9ed9;
|
||||
--form-button-color: black;
|
||||
--form-button-color-active: #4c9ed9;
|
||||
--form-button-background: #fff;
|
||||
--form-button-background-active: #f2f2f2;
|
||||
--form-button-border-color: #ccc;
|
||||
--form-button-inline-border-color: #999;
|
||||
--form-error-color: #c61d24;
|
||||
--landing-search-background: #f8f8f8;
|
||||
--landing-search-background-hover: #fefeff;
|
||||
--editor-toolbar-background: #fff;
|
||||
--editor-toolbar-border-color: transparent;
|
||||
--editor-toolbar-button-background: transparent;
|
||||
--editor-toolbar-button-background-hover: #ddd;
|
||||
--editor-toolbar-button-border-color: #ccc;
|
||||
--post-blockquote-border-color: #ddd;
|
||||
--forum-even-background: #f0f0f0;
|
||||
--forum-thread-read-color: #555;
|
||||
--forum-thread-read-link-color: #888;
|
||||
--forum-post-author-color: #333;
|
||||
--forum-diff-source-background: #fff;
|
||||
--forum-diff-source-border-color: #999;
|
||||
--forum-diff-replace-background: #adcef4;
|
||||
--forum-diff-replace-border-color: #4787d1;
|
||||
--forum-diff-delete-background: #e57979;
|
||||
--forum-diff-delete-border-color: #c12626;
|
||||
--forum-diff-insert-background: #96e579;
|
||||
--forum-diff-insert-border-color: #5baa3f;
|
||||
--card-background: #e8e8e8;
|
||||
--card-background-hover: #f0f0f0;
|
||||
--timeline-content-background: rgba(0, 0, 0, 0.2);
|
||||
--irc-border-color: #ddd;
|
||||
--irc-tab-current-shadow: 0px 0px 5px #bbb inset;
|
||||
--irc-tab-close-button-color: #fff;
|
||||
--irc-tab-close-button-background: #aaa;
|
||||
--irc-nick-border-color: #ccc;
|
||||
--irc-users-color: black;
|
||||
--irc-users-background: #fff;
|
||||
--irc-users-border-color: #ccc;
|
||||
--irc-users-popout-background: #fff;
|
||||
--irc-users-popout-border-color-left: #bbb;
|
||||
--irc-users-popout-border-color-right: #ccc;
|
||||
--code-line-number-color: #777;
|
||||
--library-star-btn-background: #fff;
|
||||
--library-star-btn-border-color: #999;
|
||||
--library-star-btn-a-border-color: #aaa;
|
||||
--library-star-btn-a-hover-background: #fafafa; }
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
After Width: | Height: | Size: 4.0 MiB |
Before Width: | Height: | Size: 32 KiB |
Before Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 52 KiB |
Before Width: | Height: | Size: 65 KiB |
Before Width: | Height: | Size: 288 KiB |
Before Width: | Height: | Size: 814 KiB |
|
@ -1,163 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="739.32581"
|
||||
height="447.97736"
|
||||
viewBox="0 0 195.61328 118.52734"
|
||||
version="1.1"
|
||||
id="svg1"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs1" /><g
|
||||
id="layer2"
|
||||
transform="translate(-6.5429683,-7.9628906)"><g
|
||||
id="g88"><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 13.785156,7.9628906 c -3.9914079,0 -7.2421873,3.2507794 -7.2421873,7.2421874 V 119.24805 c 0,3.99141 3.2507794,7.24218 7.2421873,7.24218 H 194.91406 c 3.99141,0 7.24219,-3.25077 7.24219,-7.24218 V 15.205078 c 0,-3.991408 -3.25078,-7.2421874 -7.24219,-7.2421874 z m 0,2.3808594 H 194.91406 c 2.71339,0 4.86133,2.147942 4.86133,4.861328 V 119.24805 c 0,2.71338 -2.14794,4.86132 -4.86133,4.86133 H 13.785156 c -2.713386,0 -4.8613279,-2.14795 -4.8613279,-4.86133 V 15.205078 c 0,-2.713386 2.1479419,-4.861328 4.8613279,-4.861328 z"
|
||||
id="rect1" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 8.1660156,112.96094 a 1.190625,1.190625 0 0 0 -1.1914062,1.1914 1.190625,1.190625 0 0 0 1.1914062,1.18946 H 200.73633 a 1.190625,1.190625 0 0 0 1.1914,-1.18946 1.190625,1.190625 0 0 0 -1.1914,-1.1914 z"
|
||||
id="path36" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 180.14453,118.25195 a 1.190625,1.190625 0 0 0 -1.18945,1.19141 1.190625,1.190625 0 0 0 1.18945,1.19141 h 8.94922 a 1.190625,1.190625 0 0 0 1.19141,-1.19141 1.190625,1.190625 0 0 0 -1.19141,-1.19141 z"
|
||||
id="path37" /></g><g
|
||||
id="g89"><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 39.162109,38.353516 -2.21875,0.867187 6.207032,15.861328 2.216796,-0.867187 z"
|
||||
id="path3" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 38.902344,80.289062 -4.63086,5.419922 1.808594,1.546875 4.632813,-5.419922 z"
|
||||
id="path4" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 64.087891,75.970703 -1.189454,2.0625 12.404297,7.154297 1.189453,-2.064453 z"
|
||||
id="path5" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 74.726562,52.677734 -12.070312,7.365235 1.240234,2.033203 12.070313,-7.365235 z"
|
||||
id="path2" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 83.150391,38.503906 c -5.744916,0 -10.427735,4.682819 -10.427735,10.427735 0,5.744915 4.682819,10.427734 10.427735,10.427734 5.744915,0 10.427734,-4.682819 10.427734,-10.427734 0,-5.744916 -4.682819,-10.427735 -10.427734,-10.427735 z m 0,2.38086 c 4.457993,0 8.046875,3.588881 8.046875,8.046875 0,4.457993 -3.588882,8.046875 -8.046875,8.046875 -4.457994,0 -8.044922,-3.588882 -8.044922,-8.046875 0,-4.457994 3.586928,-8.046875 8.044922,-8.046875 z"
|
||||
id="use16" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 83.757812,78.259766 c -5.744915,0 -10.427734,4.682819 -10.427734,10.427734 0,5.744915 4.682819,10.427734 10.427734,10.427734 5.744916,0 10.427735,-4.682819 10.427735,-10.427734 0,-5.744915 -4.682819,-10.427734 -10.427735,-10.427734 z m 0,2.380859 c 4.457994,0 8.046875,3.588881 8.046875,8.046875 0,4.457994 -3.588881,8.044922 -8.046875,8.044922 -4.457993,0 -8.046875,-3.586928 -8.046875,-8.044922 0,-4.457994 3.588882,-8.046875 8.046875,-8.046875 z"
|
||||
id="use17" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 29.087891,83.179687 c -5.744916,0 -10.427735,4.682819 -10.427735,10.427735 0,5.744915 4.682819,10.427738 10.427735,10.427738 5.744915,0 10.427734,-4.682823 10.427734,-10.427738 0,-5.744916 -4.682819,-10.427735 -10.427734,-10.427735 z m 0,2.38086 c 4.457993,0 8.044921,3.588881 8.044921,8.046875 0,4.457994 -3.586928,8.044918 -8.044921,8.044918 -4.457994,0 -8.046875,-3.586924 -8.046875,-8.044918 0,-4.457994 3.588881,-8.046875 8.046875,-8.046875 z"
|
||||
id="use18" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 34.621094,19.585937 c -5.744916,0 -10.427735,4.682819 -10.427735,10.427735 0,5.744915 4.682819,10.427734 10.427735,10.427734 5.744915,0 10.425781,-4.682819 10.425781,-10.427734 0,-5.744916 -4.680866,-10.427735 -10.425781,-10.427735 z m 0,2.382813 c 4.457994,0 8.044922,3.586928 8.044922,8.044922 0,4.457994 -3.586928,8.046875 -8.044922,8.046875 -4.457994,0 -8.046875,-3.588881 -8.046875,-8.046875 0,-4.457994 3.588881,-8.044922 8.046875,-8.044922 z"
|
||||
id="use83" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 49.949219,55.927734 c -7.313646,0 -13.265625,5.951979 -13.265625,13.265625 0,7.313646 5.951979,13.265625 13.265625,13.265625 7.313646,0 13.265625,-5.951979 13.265625,-13.265625 0,-7.313646 -5.951979,-13.265625 -13.265625,-13.265625 z m 0,2.117188 c 6.169714,0 11.148437,4.978724 11.148437,11.148437 0,6.169714 -4.978723,11.148438 -11.148437,11.148438 -6.169714,0 -11.148438,-4.978724 -11.148438,-11.148438 0,-6.169713 4.978724,-11.148437 11.148438,-11.148437 z"
|
||||
id="use84" /></g><g
|
||||
id="g90"><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 127.79297,52.058594 v 2.380859 H 158.75 v -2.380859 z"
|
||||
id="path61" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 171.97852,52.058594 v 2.380859 h 13.23046 v -2.380859 z"
|
||||
id="path62" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 167.48047,40.416016 v 2.380859 h 17.72851 v -2.380859 z"
|
||||
id="path63" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 138.90625,64.757812 v 2.38086 h 44.18555 v -2.38086 z"
|
||||
id="path64" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 119.85547,76.929687 v 2.38086 h 19.05078 v -2.38086 z"
|
||||
id="path65" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 174.0957,28.246094 v 2.380859 h 11.11328 v -2.380859 z"
|
||||
id="path66" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-dashoffset:8.8;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 158.75,89.628906 v 2.38086 h 11.11328 v -2.38086 z"
|
||||
id="path67" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 171.97852,25.765625 c -2.01296,0 -3.66993,1.656968 -3.66993,3.669922 0,2.012954 1.65697,3.671875 3.66993,3.671875 2.01295,0 3.67187,-1.658921 3.67187,-3.671875 0,-2.012954 -1.65892,-3.669922 -3.67187,-3.669922 z m 0,2.380859 c 0.72603,0 1.28906,0.56303 1.28906,1.289063 0,0.726032 -0.56303,1.289062 -1.28906,1.289062 -0.72604,0 -1.28907,-0.56303 -1.28907,-1.289062 0,-0.726033 0.56303,-1.289063 1.28907,-1.289063 z"
|
||||
id="use67" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 165.36523,37.9375 c -2.01295,0 -3.67187,1.656968 -3.67187,3.669922 0,2.012954 1.65892,3.669922 3.67187,3.669922 2.01296,0 3.66993,-1.656968 3.66993,-3.669922 0,-2.012954 -1.65697,-3.669922 -3.66993,-3.669922 z m 0,2.380859 c 0.72604,0 1.28907,0.56303 1.28907,1.289063 0,0.726032 -0.56303,1.289062 -1.28907,1.289062 -0.72603,0 -1.28906,-0.56303 -1.28906,-1.289062 0,-0.726033 0.56303,-1.289063 1.28906,-1.289063 z"
|
||||
id="use68" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 185.20898,62.279297 c -2.01295,0 -3.67187,1.656968 -3.67187,3.669922 0,2.012954 1.65892,3.669922 3.67187,3.669922 2.01296,0 3.66993,-1.656968 3.66993,-3.669922 0,-2.012954 -1.65697,-3.669922 -3.66993,-3.669922 z m 0,2.380859 c 0.72604,0 1.28907,0.56303 1.28907,1.289063 0,0.726032 -0.56303,1.289062 -1.28907,1.289062 -0.72603,0 -1.28906,-0.56303 -1.28906,-1.289062 0,-0.726033 0.56303,-1.289063 1.28906,-1.289063 z"
|
||||
id="use70" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 117.74023,74.449219 c -2.01295,0 -3.67187,1.656968 -3.67187,3.669922 0,2.012954 1.65892,3.669921 3.67187,3.669921 2.01296,0 3.66993,-1.656967 3.66993,-3.669921 0,-2.012954 -1.65697,-3.669922 -3.66993,-3.669922 z m 0,2.380859 c 0.72604,0 1.28907,0.56303 1.28907,1.289063 0,0.726032 -0.56303,1.289062 -1.28907,1.289062 -0.72603,0 -1.28906,-0.56303 -1.28906,-1.289062 0,-0.726033 0.56303,-1.289063 1.28906,-1.289063 z"
|
||||
id="use71" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 171.97852,87.173828 c -2.01296,0 -3.66993,1.656968 -3.66993,3.669922 0,2.012954 1.65697,3.669922 3.66993,3.669922 2.01295,0 3.67187,-1.656968 3.67187,-3.669922 0,-2.012954 -1.65892,-3.669922 -3.67187,-3.669922 z m 0,2.380859 c 0.72603,0 1.28906,0.563031 1.28906,1.289063 0,0.726032 -0.56303,1.289062 -1.28906,1.289062 -0.72604,0 -1.28907,-0.56303 -1.28907,-1.289062 0,-0.726032 0.56303,-1.289062 1.28907,-1.289063 z"
|
||||
id="use72" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use73"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="149.09836"
|
||||
y="-112.82577"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use74"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="157.70444"
|
||||
y="-104.21969"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use75"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="166.31052"
|
||||
y="-95.613609"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use76"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="147.22748"
|
||||
y="-77.278923"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use77"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="131.384"
|
||||
y="-77.899246"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use78"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="150.78217"
|
||||
y="-45.660938"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use79"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="142.17609"
|
||||
y="-54.267017"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use80"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="173.79407"
|
||||
y="-50.71233"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use81"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="156.58191"
|
||||
y="-86.633354"
|
||||
transform="rotate(45)" /><rect
|
||||
style="fill:#ffffff;stroke-width:1.89844;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
id="use82"
|
||||
width="5.3566022"
|
||||
height="5.3566022"
|
||||
x="178.84547"
|
||||
y="-73.724243"
|
||||
transform="rotate(45)" /><path
|
||||
style="color:#000000;fill:#ffffff;stroke-linecap:round;stroke-dashoffset:33.2598;-inkscape-stroke:none;paint-order:fill markers stroke"
|
||||
d="m 125.67773,49.578125 c -2.01295,0 -3.67187,1.656968 -3.67187,3.669922 0,2.012954 1.65892,3.671875 3.67187,3.671875 2.01296,0 3.66993,-1.658921 3.66993,-3.671875 0,-2.012954 -1.65697,-3.669922 -3.66993,-3.669922 z m 0,2.380859 c 0.72604,0 1.28907,0.56303 1.28907,1.289063 0,0.726032 -0.56303,1.289062 -1.28907,1.289062 -0.72603,0 -1.28906,-0.56303 -1.28906,-1.289062 0,-0.726033 0.56303,-1.289063 1.28906,-1.289063 z"
|
||||
id="use69" /></g></g></svg>
|
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 164 KiB |
Before Width: | Height: | Size: 196 KiB |
Before Width: | Height: | Size: 244 KiB |
Before Width: | Height: | Size: 2.2 KiB |
Before Width: | Height: | Size: 2.8 KiB |
|
@ -1,36 +0,0 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg width="100%" height="100%" viewBox="0 0 475 283" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:1.5;">
|
||||
<g id="Handlebars" transform="matrix(1,0,0,1,-100,-167)">
|
||||
<path d="M404,196C407.778,166.333 384.333,169 364,184" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||
</g>
|
||||
<g id="Seat">
|
||||
</g>
|
||||
<path d="M120,42L160,42C177.006,42.052 186.77,48.289 191,58" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||
<g id="Frame" transform="matrix(1,0,0,1,-100,-167)">
|
||||
<path d="M314,390L445,274" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||
<path d="M500,375L404,196" style="fill:none;stroke:white;stroke-width:9px;stroke-linecap:butt;"/>
|
||||
<path d="M266,259L437,259" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||
<path d="M314,390L248,209" style="fill:none;stroke:white;stroke-width:9px;stroke-linecap:butt;"/>
|
||||
<g transform="matrix(0.815396,-0.0212494,-0.0212494,0.997554,66.2531,7.62625)">
|
||||
<path d="M143.044,371.322L314,390" style="fill:none;stroke:white;stroke-width:10.11px;"/>
|
||||
</g>
|
||||
<path d="M175,375L266,259" style="fill:none;stroke:white;stroke-width:9px;"/>
|
||||
</g>
|
||||
<g id="Wheel" transform="matrix(1,0,0,1,-100,-167)">
|
||||
<g transform="matrix(0.980392,0,0,0.980392,-46.0784,31.3725)">
|
||||
<path d="M302,293.019C302,282.522 293.478,274 282.981,274L168.019,274C157.522,274 149,282.522 149,293.019L149,407.981C149,418.478 157.522,427 168.019,427L282.981,427C293.478,427 302,418.478 302,407.981L302,293.019ZM292.82,293.019L292.82,407.981C292.82,413.411 288.411,417.82 282.981,417.82C282.981,417.82 168.019,417.82 168.019,417.82C162.589,417.82 158.18,413.411 158.18,407.981L158.18,293.019C158.18,287.589 162.589,283.18 168.019,283.18L282.981,283.18C288.411,283.18 292.82,287.589 292.82,293.019Z" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-50,26)">
|
||||
<path d="M237,343C237,339.689 234.311,337 231,337L219,337C215.689,337 213,339.689 213,343L213,355C213,358.311 215.689,361 219,361L231,361C234.311,361 237,358.311 237,355L237,343Z" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Wheel1" serif:id="Wheel" transform="matrix(1,0,0,1,225,-167)">
|
||||
<g transform="matrix(0.980392,0,0,0.980392,-46.0784,31.3725)">
|
||||
<path d="M302,293.019C302,282.522 293.478,274 282.981,274L168.019,274C157.522,274 149,282.522 149,293.019L149,407.981C149,418.478 157.522,427 168.019,427L282.981,427C293.478,427 302,418.478 302,407.981L302,293.019ZM292.82,293.019L292.82,407.981C292.82,413.411 288.411,417.82 282.981,417.82C282.981,417.82 168.019,417.82 168.019,417.82C162.589,417.82 158.18,413.411 158.18,407.981L158.18,293.019C158.18,287.589 162.589,283.18 168.019,283.18L282.981,283.18C288.411,283.18 292.82,287.589 292.82,293.019Z" style="fill:white;"/>
|
||||
</g>
|
||||
<g transform="matrix(1,0,0,1,-50,26)">
|
||||
<path d="M237,343C237,339.689 234.311,337 231,337L219,337C215.689,337 213,339.689 213,343L213,355C213,358.311 215.689,361 219,361L231,361C234.311,361 237,358.311 237,355L237,343Z" style="fill:white;"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
Before Width: | Height: | Size: 3.4 KiB |
Before Width: | Height: | Size: 212 KiB |
|
@ -5,29 +5,23 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/assets"
|
||||
"git.handmade.network/hmn/hmn/src/auth"
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/email"
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"git.handmade.network/hmn/hmn/src/website"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
@ -515,8 +509,6 @@ func init() {
|
|||
}
|
||||
}
|
||||
|
||||
hmndata.UpdateSnippetLastPostedForAllProjects(ctx, conn)
|
||||
|
||||
fmt.Printf("Done!\n")
|
||||
},
|
||||
}
|
||||
|
@ -591,49 +583,5 @@ func init() {
|
|||
}
|
||||
adminCommand.AddCommand(extractImage)
|
||||
|
||||
uploadAsset := &cobra.Command{
|
||||
Use: "uploadasset <file> <content type>",
|
||||
Short: "Upload a file to our asset CDN",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
if len(args) < 2 {
|
||||
cmd.Usage()
|
||||
os.Exit(1)
|
||||
}
|
||||
fname := args[0]
|
||||
contentType := args[1]
|
||||
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
|
||||
assetContents := utils.Must1(io.ReadAll(utils.Must1(os.Open(fname))))
|
||||
assetFilename := filepath.Base(fname)
|
||||
|
||||
fmt.Printf("Uploading %s with content type %s...\n", assetFilename, contentType)
|
||||
asset := utils.Must1(assets.Create(ctx, conn, assets.CreateInput{
|
||||
Content: assetContents,
|
||||
Filename: assetFilename,
|
||||
ContentType: contentType,
|
||||
}))
|
||||
fmt.Printf("Uploaded and accessible at %s\n", hmnurl.BuildS3Asset(asset.S3Key))
|
||||
},
|
||||
}
|
||||
adminCommand.AddCommand(uploadAsset)
|
||||
|
||||
adminCommand.AddCommand(&cobra.Command{
|
||||
Use: "newsletteremails",
|
||||
Short: "Print a list of all newsletter email receipients",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx := context.Background()
|
||||
conn := db.NewConn()
|
||||
defer conn.Close(ctx)
|
||||
|
||||
recipients := utils.Must1(db.Query[models.NewsletterEmail](ctx, conn, `SELECT $columns FROM newsletter_emails`))
|
||||
for _, r := range recipients {
|
||||
fmt.Println(r.Email)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
addProjectCommands(adminCommand)
|
||||
}
|
||||
|
|
|
@ -1,66 +0,0 @@
|
|||
package buildcss
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"github.com/evanw/esbuild/pkg/api"
|
||||
)
|
||||
|
||||
var ActiveServerPort uint16
|
||||
|
||||
func RunServer(ctx context.Context) jobs.Job {
|
||||
job := jobs.New()
|
||||
if config.Config.Env != config.Dev {
|
||||
job.Done()
|
||||
return job
|
||||
}
|
||||
logger := logging.ExtractLogger(ctx).With().Str("module", "EsBuild").Logger()
|
||||
esCtx, ctxErr := BuildContext()
|
||||
if ctxErr != nil {
|
||||
panic(ctxErr)
|
||||
}
|
||||
logger.Info().Msg("Starting esbuild server and watcher")
|
||||
err := esCtx.Watch(api.WatchOptions{})
|
||||
serverResult, err := esCtx.Serve(api.ServeOptions{
|
||||
Port: config.Config.EsBuild.Port,
|
||||
Servedir: "./",
|
||||
OnRequest: func(args api.ServeOnRequestArgs) {
|
||||
if args.Status != 200 {
|
||||
logger.Warn().Interface("args", args).Msg("Response from esbuild server")
|
||||
}
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
ActiveServerPort = serverResult.Port
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Info().Msg("Shutting down esbuild server and watcher")
|
||||
esCtx.Dispose()
|
||||
job.Done()
|
||||
}()
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func BuildContext() (api.BuildContext, *api.ContextError) {
|
||||
return api.Context(api.BuildOptions{
|
||||
EntryPoints: []string{
|
||||
"src/rawdata/css/style.css",
|
||||
},
|
||||
Outbase: "src/rawdata/css",
|
||||
Outdir: "public",
|
||||
External: []string{"/public/*"},
|
||||
Bundle: true,
|
||||
Write: true,
|
||||
Engines: []api.Engine{
|
||||
{Name: api.EngineChrome, Version: "109"},
|
||||
{Name: api.EngineFirefox, Version: "109"},
|
||||
{Name: api.EngineSafari, Version: "12"},
|
||||
},
|
||||
})
|
||||
}
|
|
@ -1,39 +0,0 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/buildcss"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/website"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func init() {
|
||||
buildCommand := &cobra.Command{
|
||||
Use: "buildcss",
|
||||
Short: "Build the website CSS",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
ctx, err := buildcss.BuildContext()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
res := ctx.Rebuild()
|
||||
outputFilenames := make([]string, 0)
|
||||
for _, o := range res.OutputFiles {
|
||||
outputFilenames = append(outputFilenames, o.Path)
|
||||
}
|
||||
logging.Info().
|
||||
Interface("Errors", res.Errors).
|
||||
Interface("Warnings", res.Warnings).
|
||||
Msg("Ran esbuild")
|
||||
if len(outputFilenames) > 0 {
|
||||
logging.Info().Interface("Files", outputFilenames).Msg("Wrote files")
|
||||
}
|
||||
},
|
||||
}
|
||||
website.WebsiteCommand.AddCommand(buildCommand)
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
package buildscss
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
color "git.handmade.network/hmn/hmn/src/ansicolor"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/website"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/wellington/go-libsass"
|
||||
)
|
||||
|
||||
var compressed bool
|
||||
|
||||
func init() {
|
||||
libsass.RegisterSassFunc("base64($filename)", func(ctx context.Context, in libsass.SassValue) (*libsass.SassValue, error) {
|
||||
var filename string
|
||||
err := libsass.Unmarshal(in, &filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fileBytes, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
encoded, _ := libsass.Marshal(base64.StdEncoding.EncodeToString(fileBytes))
|
||||
return &encoded, nil
|
||||
})
|
||||
|
||||
buildCommand := &cobra.Command{
|
||||
Use: "buildscss",
|
||||
Short: "Build the website CSS",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
style := libsass.NESTED_STYLE
|
||||
if compressed {
|
||||
style = libsass.COMPRESSED_STYLE
|
||||
}
|
||||
|
||||
err := compile("src/rawdata/scss/style.scss", "public/style.css", "light", style)
|
||||
if err != nil {
|
||||
fmt.Println(color.Bold + color.Red + "Failed to compile main SCSS." + color.Reset)
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
for _, theme := range []string{"light", "dark"} {
|
||||
err := compile("src/rawdata/scss/theme.scss", fmt.Sprintf("public/themes/%s/theme.css", theme), theme, style)
|
||||
if err != nil {
|
||||
fmt.Println(color.Bold + color.Red + "Failed to compile theme SCSS." + color.Reset)
|
||||
fmt.Println(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
buildCommand.Flags().BoolVar(&compressed, "compressed", false, "Minify the output CSS")
|
||||
|
||||
website.WebsiteCommand.AddCommand(buildCommand)
|
||||
}
|
||||
|
||||
func compile(inpath, outpath string, theme string, style int) error {
|
||||
err := os.MkdirAll(filepath.Dir(outpath), 0755)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create directory for CSS file"))
|
||||
}
|
||||
|
||||
outfile, err := os.OpenFile(outpath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to open CSS file for writing"))
|
||||
}
|
||||
defer outfile.Close()
|
||||
|
||||
infile, err := os.Open(inpath)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to open SCSS file"))
|
||||
}
|
||||
compiler, err := libsass.New(outfile, infile,
|
||||
libsass.IncludePaths([]string{
|
||||
"src/rawdata/scss",
|
||||
fmt.Sprintf("src/rawdata/scss/themes/%s", theme),
|
||||
}),
|
||||
libsass.OutputStyle(style),
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create SCSS compiler"))
|
||||
}
|
||||
|
||||
return compiler.Run()
|
||||
}
|
|
@ -1,383 +0,0 @@
|
|||
package calendar
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/jobs"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/emersion/go-ical"
|
||||
)
|
||||
|
||||
type RawCalendarData struct {
|
||||
Name string
|
||||
Url string
|
||||
Data []byte
|
||||
Hash [sha1.Size]byte
|
||||
}
|
||||
|
||||
type CalendarEvent struct {
|
||||
ID string
|
||||
Name string
|
||||
Desc string
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
Duration time.Duration
|
||||
CalName string
|
||||
}
|
||||
|
||||
var unifiedCalendar *ical.Calendar
|
||||
var rawCalendarData = make([]*RawCalendarData, 0)
|
||||
var cachedICals = make(map[string][]byte)
|
||||
var httpClient = http.Client{}
|
||||
|
||||
// NOTE(asaf): Passing an empty array for selectedCals returns all cals
|
||||
func GetICal(selectedCals []string) ([]byte, error) {
|
||||
if unifiedCalendar == nil {
|
||||
return nil, oops.New(nil, "No calendar")
|
||||
}
|
||||
sort.Strings(selectedCals)
|
||||
cacheKey := strings.Join(selectedCals, "##")
|
||||
cachedICal, ok := cachedICals[cacheKey]
|
||||
if ok {
|
||||
return cachedICal, nil
|
||||
}
|
||||
|
||||
var cal *ical.Calendar
|
||||
if len(selectedCals) == 0 {
|
||||
cal = unifiedCalendar
|
||||
} else {
|
||||
cal = newHMNCalendar()
|
||||
for _, child := range unifiedCalendar.Children {
|
||||
include := true
|
||||
|
||||
if child.Name == ical.CompEvent {
|
||||
calName, _ := child.Props.Text(ical.PropComment)
|
||||
if calName != "" {
|
||||
found := false
|
||||
for _, s := range selectedCals {
|
||||
if calName == s {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
include = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if include {
|
||||
cal.Children = append(cal.Children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
var calBytes []byte
|
||||
if len(cal.Children) > 0 {
|
||||
var buffer bytes.Buffer
|
||||
err := ical.NewEncoder(&buffer).Encode(cal)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "Failed to encode calendar to iCal")
|
||||
}
|
||||
calBytes = buffer.Bytes()
|
||||
} else {
|
||||
calBytes = emptyCalendarString()
|
||||
}
|
||||
cachedICals[cacheKey] = calBytes
|
||||
return calBytes, nil
|
||||
}
|
||||
|
||||
func GetFutureEvents() []CalendarEvent {
|
||||
if unifiedCalendar == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
futureEvents := make([]CalendarEvent, 0)
|
||||
eventObjects := unifiedCalendar.Events()
|
||||
now := time.Now()
|
||||
lastTime := now.Add(time.Hour * 24 * 365)
|
||||
for _, ev := range eventObjects {
|
||||
summary, err := ev.Props.Text(ical.PropSummary)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("Failed to get summary for calendar event")
|
||||
continue
|
||||
}
|
||||
|
||||
startTime, err := ev.DateTimeStart(nil)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get start time for calendar event")
|
||||
continue
|
||||
}
|
||||
|
||||
var evTimes []time.Time
|
||||
set, err := ev.RecurrenceSet(nil)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get recurrence set for calendar event")
|
||||
continue
|
||||
}
|
||||
if set != nil {
|
||||
evTimes = set.Between(now, lastTime, true)
|
||||
} else if startTime.After(now) {
|
||||
evTimes = []time.Time{startTime}
|
||||
}
|
||||
|
||||
if len(evTimes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
desc, err := ev.Props.Text(ical.PropDescription)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get description for calendar event")
|
||||
continue
|
||||
}
|
||||
|
||||
calName, _ := ev.Props.Text(ical.PropComment)
|
||||
|
||||
uid, err := ev.Props.Text(ical.PropUID)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get uid for calendar event")
|
||||
continue
|
||||
}
|
||||
|
||||
endTime, err := ev.DateTimeStart(nil)
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Str("Event name", summary).Msg("Failed to get end time for calendar event")
|
||||
continue
|
||||
}
|
||||
|
||||
evDuration := endTime.Sub(startTime)
|
||||
|
||||
for _, t := range evTimes {
|
||||
futureEvents = append(futureEvents, CalendarEvent{
|
||||
ID: uid,
|
||||
Name: summary,
|
||||
Desc: desc,
|
||||
StartTime: t,
|
||||
EndTime: t.Add(evDuration),
|
||||
Duration: evDuration,
|
||||
CalName: calName,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(futureEvents, func(i, j int) bool {
|
||||
return futureEvents[i].StartTime.Before(futureEvents[j].StartTime)
|
||||
})
|
||||
return futureEvents
|
||||
}
|
||||
|
||||
func MonitorCalendars(ctx context.Context) jobs.Job {
|
||||
log := logging.ExtractLogger(ctx).With().Str("calendar goroutine", "calendar monitor").Logger()
|
||||
if len(config.Config.Calendars) == 0 {
|
||||
log.Info().Msg("No calendars specified in config")
|
||||
return jobs.Noop()
|
||||
}
|
||||
|
||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||
|
||||
job := jobs.New()
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
log.Info().Msg("Shutting down calendar monitor")
|
||||
job.Done()
|
||||
}()
|
||||
log.Info().Msg("Running calendar monitor")
|
||||
|
||||
monitorTimer := time.NewTimer(time.Second)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-monitorTimer.C:
|
||||
err := func() (err error) {
|
||||
defer utils.RecoverPanicAsError(&err)
|
||||
|
||||
ReloadCalendars(ctx)
|
||||
|
||||
return nil
|
||||
}()
|
||||
if err != nil {
|
||||
logging.Error().Err(err).Msg("Panicked in MonitorCalendars")
|
||||
}
|
||||
monitorTimer.Reset(60 * time.Minute)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return job
|
||||
}
|
||||
|
||||
func ReloadCalendars(ctx context.Context) {
|
||||
log := logging.ExtractLogger(ctx)
|
||||
|
||||
// Download calendars
|
||||
calChan := make(chan RawCalendarData, len(config.Config.Calendars))
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(config.Config.Calendars))
|
||||
for _, c := range config.Config.Calendars {
|
||||
go func(cal config.CalendarSource) {
|
||||
defer func() {
|
||||
wg.Done()
|
||||
logging.LogPanics(log)
|
||||
}()
|
||||
calUrl := cal.Url
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", calUrl, nil)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("Failed to create request for calendar fetch")
|
||||
return
|
||||
}
|
||||
res, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
|
||||
return
|
||||
}
|
||||
if res.StatusCode > 299 || !strings.HasPrefix(res.Header.Get("Content-Type"), "text/calendar") {
|
||||
log.Error().Str("Url", calUrl).Str("Status", res.Status).Msg("Failed to fetch calendar")
|
||||
io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
return
|
||||
}
|
||||
|
||||
data, err := io.ReadAll(res.Body)
|
||||
res.Body.Close()
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("Url", calUrl).Msg("Failed to fetch calendar")
|
||||
return
|
||||
}
|
||||
|
||||
calChan <- RawCalendarData{Name: cal.Name, Url: calUrl, Data: data}
|
||||
}(c)
|
||||
}
|
||||
wg.Wait()
|
||||
newRawCalendarData := make([]*RawCalendarData, 0, len(config.Config.Calendars))
|
||||
Collect:
|
||||
for {
|
||||
select {
|
||||
case d := <-calChan:
|
||||
newRawCalendarData = append(newRawCalendarData, &d)
|
||||
default:
|
||||
break Collect
|
||||
}
|
||||
}
|
||||
|
||||
// Diff calendars
|
||||
same := true
|
||||
for _, n := range newRawCalendarData {
|
||||
n.Hash = sha1.Sum(n.Data)
|
||||
}
|
||||
|
||||
sort.Slice(newRawCalendarData, func(i, j int) bool {
|
||||
return newRawCalendarData[i].Name < newRawCalendarData[j].Name
|
||||
})
|
||||
|
||||
if len(newRawCalendarData) != len(rawCalendarData) {
|
||||
same = false
|
||||
} else {
|
||||
for i := range newRawCalendarData {
|
||||
newData := newRawCalendarData[i]
|
||||
oldData := rawCalendarData[i]
|
||||
if newData.Name != oldData.Name {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
if newData.Hash != oldData.Hash {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if same {
|
||||
return
|
||||
}
|
||||
|
||||
// Unify calendars and clear cache
|
||||
rawCalendarData = newRawCalendarData
|
||||
cachedICals = make(map[string][]byte)
|
||||
|
||||
unified := newHMNCalendar()
|
||||
|
||||
var timezones []string
|
||||
|
||||
for _, calData := range rawCalendarData {
|
||||
decoder := ical.NewDecoder(bytes.NewReader(calData.Data))
|
||||
calNameProp := ical.NewProp(ical.PropComment)
|
||||
calNameProp.SetText(calData.Name)
|
||||
for {
|
||||
cal, err := decoder.Decode()
|
||||
if err == io.EOF {
|
||||
break
|
||||
} else if err != nil {
|
||||
log.Error().Err(err).Str("Url", calData.Url).Msg("Failed to parse calendar")
|
||||
break
|
||||
}
|
||||
|
||||
for _, child := range cal.Children {
|
||||
if child.Name == ical.CompTimezone {
|
||||
tzid, err := child.Props.Text(ical.PropTimezoneID)
|
||||
if err != nil {
|
||||
found := false
|
||||
for _, s := range timezones {
|
||||
if s == tzid {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if found {
|
||||
continue
|
||||
} else {
|
||||
timezones = append(timezones, tzid)
|
||||
}
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if child.Name == ical.CompEvent {
|
||||
child.Props.Set(calNameProp)
|
||||
}
|
||||
unified.Children = append(unified.Children, child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unifiedCalendar = unified
|
||||
}
|
||||
|
||||
func newHMNCalendar() *ical.Calendar {
|
||||
cal := ical.NewCalendar()
|
||||
|
||||
prodID := ical.NewProp(ical.PropProductID)
|
||||
prodID.SetText("Handmade Network")
|
||||
cal.Props.Set(prodID)
|
||||
|
||||
version := ical.NewProp(ical.PropVersion)
|
||||
version.SetText("1.0")
|
||||
cal.Props.Set(version)
|
||||
|
||||
name := ical.NewProp("X-WR-CALNAME")
|
||||
name.SetText("Handmade Network")
|
||||
cal.Props.Set(name)
|
||||
|
||||
return cal
|
||||
}
|
||||
|
||||
// NOTE(asaf): The ical library we're using doesn't like encoding empty calendars, so we have to do this manually.
|
||||
func emptyCalendarString() []byte {
|
||||
empty := `BEGIN:VCALENDAR
|
||||
VERSION:1.0
|
||||
PRODID:Handmade Network
|
||||
X-WR-CALNAME:Handmade Network
|
||||
END:VCALENDAR
|
||||
`
|
||||
|
||||
return []byte(empty)
|
||||
}
|
|
@ -23,7 +23,6 @@ var Config = HMNConfig{
|
|||
LogLevel: tracelog.LogLevelError, // LogLevelWarn is recommended for production
|
||||
MinConn: 2, // Keep these low for dev, high for production
|
||||
MaxConn: 10,
|
||||
SlowQueryThresholdMs: 200,
|
||||
},
|
||||
Auth: AuthConfig{
|
||||
CookieDomain: ".handmade.local",
|
||||
|
@ -79,8 +78,6 @@ var Config = HMNConfig{
|
|||
BaseUrl: "https://api.twitch.tv/helix",
|
||||
BaseIDUrl: "https://id.twitch.tv/oauth2",
|
||||
},
|
||||
Calendars: []CalendarSource{
|
||||
},
|
||||
EpisodeGuide: EpisodeGuide{
|
||||
CineraOutputPath: "./annotations/",
|
||||
Projects: map[string]string{"hero": "code", "riscy": "riscy", "bitwise": "bitwise"},
|
||||
|
@ -92,7 +89,4 @@ var Config = HMNConfig{
|
|||
FFMpegPath: "", // Will not generate asset video thumbnails if ffmpeg is not specified
|
||||
CPULimitPath: "", // Not mandatory. FFMpeg will not limited if this is not provided
|
||||
},
|
||||
EsBuild: EsBuildConfig{
|
||||
Port: 9004,
|
||||
},
|
||||
}
|
||||
|
|
|
@ -28,24 +28,20 @@ type HMNConfig struct {
|
|||
DigitalOcean DigitalOceanConfig
|
||||
Discord DiscordConfig
|
||||
Twitch TwitchConfig
|
||||
Matrix MatrixConfig
|
||||
EpisodeGuide EpisodeGuide
|
||||
DevConfig DevConfig
|
||||
PreviewGeneration PreviewGenerationConfig
|
||||
Calendars []CalendarSource
|
||||
EsBuild EsBuildConfig
|
||||
}
|
||||
|
||||
type PostgresConfig struct {
|
||||
User string
|
||||
Password string
|
||||
Hostname string
|
||||
Port int
|
||||
DbName string
|
||||
LogLevel tracelog.LogLevel
|
||||
MinConn int32
|
||||
MaxConn int32
|
||||
SlowQueryThresholdMs int
|
||||
User string
|
||||
Password string
|
||||
Hostname string
|
||||
Port int
|
||||
DbName string
|
||||
LogLevel tracelog.LogLevel
|
||||
MinConn int32
|
||||
MaxConn int32
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
|
@ -97,18 +93,6 @@ type TwitchConfig struct {
|
|||
BaseIDUrl string
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
BaseUrl string
|
||||
AnnouncementsRoomID string
|
||||
}
|
||||
|
||||
type CalendarSource struct {
|
||||
Name string
|
||||
Url string
|
||||
}
|
||||
|
||||
type EpisodeGuide struct {
|
||||
CineraOutputPath string
|
||||
Projects map[string]string // NOTE(asaf): Maps from slugs to default episode guide topic
|
||||
|
@ -128,10 +112,6 @@ type PreviewGenerationConfig struct {
|
|||
CPULimitPath string
|
||||
}
|
||||
|
||||
type EsBuildConfig struct {
|
||||
Port uint16
|
||||
}
|
||||
|
||||
func init() {
|
||||
if Config.EpisodeGuide.Projects == nil {
|
||||
Config.EpisodeGuide.Projects = make(map[string]string)
|
||||
|
|
19
src/db/db.go
|
@ -7,7 +7,6 @@ import (
|
|||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/logging"
|
||||
|
@ -63,8 +62,8 @@ func NewConnWithConfig(cfg config.PostgresConfig) *pgx.Conn {
|
|||
pgcfg, err := pgx.ParseConfig(cfg.DSN())
|
||||
|
||||
pgcfg.Tracer = &tracelog.TraceLog{
|
||||
Logger: zerologadapter.NewLogger(log.Logger),
|
||||
LogLevel: cfg.LogLevel,
|
||||
zerologadapter.NewLogger(log.Logger),
|
||||
cfg.LogLevel,
|
||||
}
|
||||
|
||||
conn, err := pgx.ConnectConfig(context.Background(), pgcfg)
|
||||
|
@ -89,8 +88,8 @@ func NewConnPoolWithConfig(cfg config.PostgresConfig) *pgxpool.Pool {
|
|||
pgcfg.MinConns = cfg.MinConn
|
||||
pgcfg.MaxConns = cfg.MaxConn
|
||||
pgcfg.ConnConfig.Tracer = &tracelog.TraceLog{
|
||||
Logger: zerologadapter.NewLogger(log.Logger),
|
||||
LogLevel: cfg.LogLevel,
|
||||
zerologadapter.NewLogger(log.Logger),
|
||||
cfg.LogLevel,
|
||||
}
|
||||
|
||||
conn, err := pgxpool.NewWithConfig(context.Background(), pgcfg)
|
||||
|
@ -299,7 +298,6 @@ func QueryIterator[T any](
|
|||
|
||||
compiled := compileQuery(query, destType)
|
||||
|
||||
queryStart := time.Now()
|
||||
rows, err := conn.Query(ctx, compiled.query, args...)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
|
@ -307,14 +305,6 @@ func QueryIterator[T any](
|
|||
}
|
||||
return nil, err
|
||||
}
|
||||
duration := time.Now().Sub(queryStart)
|
||||
if config.Config.Postgres.SlowQueryThresholdMs > 0 && duration > time.Duration(config.Config.Postgres.SlowQueryThresholdMs)*time.Millisecond {
|
||||
logging.Warn().
|
||||
Interface("Duration", duration.String()).
|
||||
Interface("Query", strings.ReplaceAll(strings.ReplaceAll(compiled.query, "\n", " "), "\t", " ")).
|
||||
Interface("Args", args).
|
||||
Msg("Slow query")
|
||||
}
|
||||
|
||||
it := &Iterator[T]{
|
||||
fieldPaths: compiled.fieldPaths,
|
||||
|
@ -489,7 +479,6 @@ This is common for custom types like:
|
|||
*/
|
||||
var queryableKinds = []reflect.Kind{
|
||||
reflect.Int,
|
||||
reflect.String,
|
||||
}
|
||||
|
||||
/*
|
||||
|
|
|
@ -17,6 +17,7 @@ import (
|
|||
const SlashCommandProfile = "profile"
|
||||
const ProfileOptionUser = "user"
|
||||
|
||||
const SlashCommandWishlist = "wishlist"
|
||||
const SlashCommandManifesto = "manifesto"
|
||||
|
||||
// User command names
|
||||
|
@ -49,6 +50,12 @@ func (bot *botInstance) createApplicationCommands(ctx context.Context) {
|
|||
Name: UserCommandProfile,
|
||||
}))
|
||||
|
||||
doOrWarn(CreateGuildApplicationCommand(ctx, CreateGuildApplicationCommandRequest{
|
||||
Type: ApplicationCommandTypeChatInput,
|
||||
Name: SlashCommandWishlist,
|
||||
Description: "Check out the Handmade Network wishlist",
|
||||
}))
|
||||
|
||||
doOrWarn(CreateGuildApplicationCommand(ctx, CreateGuildApplicationCommandRequest{
|
||||
Type: ApplicationCommandTypeChatInput,
|
||||
Name: SlashCommandManifesto,
|
||||
|
@ -76,6 +83,17 @@ func (bot *botInstance) doInteraction(ctx context.Context, i *Interaction) {
|
|||
bot.handleProfileCommand(ctx, i, userID)
|
||||
case UserCommandProfile:
|
||||
bot.handleProfileCommand(ctx, i, i.Data.TargetID)
|
||||
case SlashCommandWishlist:
|
||||
err := CreateInteractionResponse(ctx, i.ID, i.Token, InteractionResponse{
|
||||
Type: InteractionCallbackTypeChannelMessageWithSource,
|
||||
Data: &InteractionCallbackData{
|
||||
Content: "Check out the Handmade Network wishlist at https://github.com/HandmadeNetwork/wishlist/discussions/",
|
||||
Flags: FlagEphemeral,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logging.ExtractLogger(ctx).Error().Err(err).Msg("failed to send wishlist response")
|
||||
}
|
||||
case SlashCommandManifesto:
|
||||
err := CreateInteractionResponse(ctx, i.ID, i.Token, InteractionResponse{
|
||||
Type: InteractionCallbackTypeChannelMessageWithSource,
|
||||
|
|
|
@ -23,34 +23,6 @@ import (
|
|||
"github.com/jpillora/backoff"
|
||||
)
|
||||
|
||||
type BotEvent struct {
|
||||
Timestamp time.Time
|
||||
Name string
|
||||
Extra string
|
||||
}
|
||||
|
||||
var botEvents = make([]BotEvent, 0, 1000)
|
||||
var botEventsMutex = sync.Mutex{}
|
||||
|
||||
func RecordBotEvent(name, extra string) {
|
||||
botEventsMutex.Lock()
|
||||
defer botEventsMutex.Unlock()
|
||||
if len(botEvents) > 1000 {
|
||||
botEvents = botEvents[len(botEvents)-500:]
|
||||
}
|
||||
botEvents = append(botEvents, BotEvent{
|
||||
Timestamp: time.Now(),
|
||||
Name: name,
|
||||
Extra: extra,
|
||||
})
|
||||
}
|
||||
|
||||
func GetBotEvents() []BotEvent {
|
||||
botEventsMutex.Lock()
|
||||
defer botEventsMutex.Unlock()
|
||||
return botEvents[:]
|
||||
}
|
||||
|
||||
func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
||||
log := logging.ExtractLogger(ctx).With().Str("module", "discord").Logger()
|
||||
ctx = logging.AttachLoggerToContext(&log, ctx)
|
||||
|
@ -84,11 +56,6 @@ func RunDiscordBot(ctx context.Context, dbConn *pgxpool.Pool) jobs.Job {
|
|||
log.Info().Msg("Connecting to the Discord gateway")
|
||||
bot := newBotInstance(dbConn)
|
||||
err := bot.Run(ctx)
|
||||
disconnectMessage := ""
|
||||
if err != nil {
|
||||
disconnectMessage = err.Error()
|
||||
}
|
||||
RecordBotEvent("Disconnected", disconnectMessage)
|
||||
if err != nil {
|
||||
dur := boff.Duration()
|
||||
log.Error().
|
||||
|
@ -134,8 +101,6 @@ type botInstance struct {
|
|||
conn *websocket.Conn
|
||||
dbConn *pgxpool.Pool
|
||||
|
||||
resuming bool
|
||||
|
||||
heartbeatIntervalMs int
|
||||
forceHeartbeat chan struct{}
|
||||
|
||||
|
@ -228,7 +193,6 @@ func (bot *botInstance) Run(ctx context.Context) (err error) {
|
|||
logging.ExtractLogger(ctx).Info().Msg("Discord asked us to reconnect to the gateway")
|
||||
return nil
|
||||
case OpcodeInvalidSession:
|
||||
RecordBotEvent("Failed to resume - invalid session", "")
|
||||
// We tried to resume but the session was invalid.
|
||||
// Delete the session and reconnect from scratch again.
|
||||
_, err := bot.dbConn.Exec(ctx, `DELETE FROM discord_session`)
|
||||
|
@ -300,11 +264,8 @@ func (bot *botInstance) connect(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
RecordBotEvent("Connected", "")
|
||||
if shouldResume {
|
||||
RecordBotEvent("Resuming with session ID", session.ID)
|
||||
// Reconnect to the previous session
|
||||
bot.resuming = true
|
||||
err := bot.sendGatewayMessage(ctx, GatewayMessage{
|
||||
Opcode: OpcodeResume,
|
||||
Data: Resume{
|
||||
|
@ -579,20 +540,11 @@ func (bot *botInstance) processEventMsg(ctx context.Context, msg *GatewayMessage
|
|||
panic(fmt.Sprintf("processEventMsg must only be used on Dispatch messages (opcode %d). Validate this before you call this function.", OpcodeDispatch))
|
||||
}
|
||||
|
||||
if bot.resuming {
|
||||
name := ""
|
||||
if msg.EventName != nil {
|
||||
name = *msg.EventName
|
||||
}
|
||||
RecordBotEvent("Got event while resuming", name)
|
||||
}
|
||||
switch *msg.EventName {
|
||||
case "RESUMED":
|
||||
// Nothing to do, but at least we can log something
|
||||
logging.ExtractLogger(ctx).Info().Msg("Finished resuming gateway session")
|
||||
|
||||
bot.resuming = false
|
||||
RecordBotEvent("Done resuming", "")
|
||||
bot.createApplicationCommands(ctx)
|
||||
case "MESSAGE_CREATE":
|
||||
newMessage := *MessageFromMap(msg.Data, "")
|
||||
|
|
|
@ -185,7 +185,6 @@ func Scrape(ctx context.Context, dbConn *pgxpool.Pool, channelID string, earlies
|
|||
return true
|
||||
}
|
||||
|
||||
msg.Backfilled = true
|
||||
err := HandleIncomingMessage(ctx, dbConn, &msg, createSnippets)
|
||||
|
||||
if err != nil {
|
||||
|
|
|
@ -1,17 +1,13 @@
|
|||
package discord
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -23,15 +19,9 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var autostoreChannels = []string{
|
||||
config.Config.Discord.ShowcaseChannelID,
|
||||
// TODO(asaf): Add jam channel
|
||||
}
|
||||
|
||||
func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, createSnippets bool) error {
|
||||
deleted := false
|
||||
var err error
|
||||
|
@ -46,10 +36,6 @@ func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
|||
deleted, err = CleanUpShowcase(ctx, dbConn, msg)
|
||||
}
|
||||
|
||||
if !deleted && err == nil {
|
||||
err = ShareToMatrix(ctx, msg)
|
||||
}
|
||||
|
||||
if !deleted && err == nil {
|
||||
err = MaybeInternMessage(ctx, dbConn, msg)
|
||||
}
|
||||
|
@ -58,11 +44,6 @@ func HandleIncomingMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
|||
err = HandleInternedMessage(ctx, dbConn, msg, deleted, createSnippets)
|
||||
}
|
||||
|
||||
// when we needed her most...she vanished
|
||||
// if !deleted && err == nil {
|
||||
// err = FreyaMode(ctx, dbConn, msg)
|
||||
// }
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -75,7 +56,17 @@ func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (boo
|
|||
return deleted, nil
|
||||
}
|
||||
|
||||
if !messageShouldBeStored(msg) {
|
||||
hasGoodContent := true
|
||||
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||
hasGoodContent = false
|
||||
}
|
||||
|
||||
hasGoodAttachments := true
|
||||
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||
hasGoodAttachments = false
|
||||
}
|
||||
|
||||
if !hasGoodContent && !hasGoodAttachments {
|
||||
err := DeleteMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return deleted, oops.New(err, "failed to delete message")
|
||||
|
@ -91,7 +82,7 @@ func CleanUpShowcase(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (boo
|
|||
err = SendMessages(ctx, dbConn, MessageToSend{
|
||||
ChannelID: channel.ID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: "Posts in #project-showcase are required to have either an image/video or a link, or start with `!til`. Discuss showcase content in #projects.",
|
||||
Content: "Posts in #project-showcase are required to have either an image/video or a link. Discuss showcase content in #projects.",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
@ -146,163 +137,8 @@ func CleanUpLibrary(ctx context.Context, dbConn db.ConnOrTx, msg *Message) (bool
|
|||
return deleted, nil
|
||||
}
|
||||
|
||||
func FreyaMode(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
||||
if msg.Author.IsBot {
|
||||
return nil
|
||||
}
|
||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID || msg.ChannelID == config.Config.Discord.LibraryChannelID {
|
||||
return nil
|
||||
}
|
||||
|
||||
twitteryUrls := []string{
|
||||
"https://twitter.com",
|
||||
"https://x.com",
|
||||
"https://vxtwitter.com",
|
||||
"https://fxtwitter.com",
|
||||
}
|
||||
isTwittery := false
|
||||
for _, url := range twitteryUrls {
|
||||
if strings.Contains(msg.Content, url) {
|
||||
isTwittery = true
|
||||
}
|
||||
}
|
||||
if !isTwittery {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FREYA MODE ENGAGED
|
||||
approvedTweets := []string{
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1757836988495847568",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1752441092501361103",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1753813557966217268",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1746228932188295579",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1732687685850894799",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1761487879178736048",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1733820461492863442",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1732845451701871101",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1765680355657359585",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1784678195997852129",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1741468609044508831",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1759306434053870012",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1754929898492162178",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1782498313511534822",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1623737764041695232",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1718979996125925494",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1675945798448607248",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1662229911375953922",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1652235944752185345",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1386408507218427905",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1436696408506212353",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1444755552777670657",
|
||||
"https://vxtwitter.com/FreyaHolmer/status/1232826293902888960",
|
||||
}
|
||||
tweet := approvedTweets[rand.Intn(len(approvedTweets))]
|
||||
err := SendMessages(ctx, dbConn, MessageToSend{
|
||||
ChannelID: msg.ChannelID,
|
||||
Req: CreateMessageRequest{
|
||||
Content: fmt.Sprintf("No. Only Freya is allowed to tweet. %s", tweet),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to send Freya tweet")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func ShareToMatrix(ctx context.Context, msg *Message) error {
|
||||
if msg.Flags&MessageFlagCrossposted == 0 {
|
||||
return nil
|
||||
}
|
||||
if config.Config.Matrix.Username == "" {
|
||||
logging.ExtractLogger(ctx).Warn().Msg("No Matrix user provided; Discord announcement will not be shared")
|
||||
}
|
||||
|
||||
fullMsg, err := GetChannelMessage(ctx, msg.ChannelID, msg.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to get published message contents")
|
||||
}
|
||||
|
||||
bodyMarkdown := CleanUpMarkdown(ctx, fullMsg.Content)
|
||||
bodyHTML := parsing.ParseMarkdown(bodyMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
// Log in to Matrix (we don't bother to keep access tokens around)
|
||||
var accessToken string
|
||||
{
|
||||
type MatrixLogin struct {
|
||||
Type string `json:"type"`
|
||||
User string `json:"user"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
type MatrixLoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
body := MatrixLogin{
|
||||
Type: "m.login.password",
|
||||
User: config.Config.Matrix.Username,
|
||||
Password: config.Config.Matrix.Password,
|
||||
}
|
||||
bodyBytes := utils.Must1(json.Marshal(body))
|
||||
res, err := http.Post(
|
||||
"https://matrix.handmadecities.com/_matrix/client/r0/login",
|
||||
"application/json",
|
||||
bytes.NewReader(bodyBytes),
|
||||
)
|
||||
if err != nil || res.StatusCode >= 300 {
|
||||
return oops.New(err, "failed to log into Matrix")
|
||||
}
|
||||
defer res.Body.Close()
|
||||
resBodyBytes := utils.Must1(io.ReadAll(res.Body))
|
||||
var resBody MatrixLoginResponse
|
||||
utils.Must(json.Unmarshal(resBodyBytes, &resBody))
|
||||
|
||||
accessToken = resBody.AccessToken
|
||||
}
|
||||
|
||||
// Create message
|
||||
{
|
||||
type MessageEvent struct {
|
||||
MsgType string `json:"msgtype"`
|
||||
Body string `json:"body"`
|
||||
Format string `json:"format,omitempty"`
|
||||
FormattedBody string `json:"formatted_body,omitempty"`
|
||||
}
|
||||
tid := "hmn" + strconv.Itoa(rand.Int())
|
||||
body := MessageEvent{
|
||||
MsgType: "m.text",
|
||||
Body: bodyMarkdown,
|
||||
Format: "org.matrix.custom.html",
|
||||
FormattedBody: bodyHTML,
|
||||
}
|
||||
bodyBytes := utils.Must1(json.Marshal(body))
|
||||
req := utils.Must1(http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPut,
|
||||
fmt.Sprintf(
|
||||
"%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s",
|
||||
config.Config.Matrix.BaseUrl,
|
||||
config.Config.Matrix.AnnouncementsRoomID,
|
||||
tid,
|
||||
),
|
||||
bytes.NewReader(bodyBytes),
|
||||
))
|
||||
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", accessToken))
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil || res.StatusCode >= 300 {
|
||||
return oops.New(err, "failed to send Matrix message")
|
||||
}
|
||||
}
|
||||
|
||||
logging.ExtractLogger(ctx).Info().
|
||||
Str("contents", bodyMarkdown).
|
||||
Msg("Published Discord announcement to Matrix")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func MaybeInternMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message) error {
|
||||
if messageShouldBeStored(msg) {
|
||||
if msg.ChannelID == config.Config.Discord.ShowcaseChannelID {
|
||||
err := InternMessage(ctx, dbConn, msg)
|
||||
if errors.Is(err, errNotEnoughInfo) {
|
||||
logging.ExtractLogger(ctx).Warn().
|
||||
|
@ -354,8 +190,8 @@ func InternMessage(
|
|||
|
||||
_, err = dbConn.Exec(ctx,
|
||||
`
|
||||
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created, backfilled)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
INSERT INTO discord_message (id, channel_id, guild_id, url, user_id, sent_at, snippet_created)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
msg.ID,
|
||||
msg.ChannelID,
|
||||
|
@ -364,7 +200,6 @@ func InternMessage(
|
|||
msg.Author.ID,
|
||||
msg.Time(),
|
||||
false,
|
||||
msg.Backfilled,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to save new discord message")
|
||||
|
@ -406,7 +241,7 @@ func FetchInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msgId string)
|
|||
// 1. Saves/updates content
|
||||
// 2. Saves/updates snippet
|
||||
// 3. Deletes content/snippet
|
||||
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, removeInternedMessage bool, createSnippet bool) error {
|
||||
func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message, deleted bool, createSnippet bool) error {
|
||||
tx, err := dbConn.Begin(ctx)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to start transaction")
|
||||
|
@ -417,11 +252,7 @@ func HandleInternedMessage(ctx context.Context, dbConn db.ConnOrTx, msg *Message
|
|||
if err != nil && !errors.Is(err, db.NotFound) {
|
||||
return err
|
||||
} else if err == nil {
|
||||
if !removeInternedMessage {
|
||||
removeInternedMessage = !messageShouldBeStored(msg)
|
||||
}
|
||||
|
||||
if !removeInternedMessage {
|
||||
if !deleted {
|
||||
err = SaveMessageContents(ctx, tx, interned, msg)
|
||||
if err != nil {
|
||||
return err
|
||||
|
@ -854,7 +685,7 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
|
||||
if interned.MessageContent == nil {
|
||||
// NOTE(asaf): Can't have a snippet without content
|
||||
// NOTE(asaf): Messages that only have an attachment also have a content struct with an empty content string
|
||||
// NOTE(asaf): Messages that only have an attachment also have blank content
|
||||
// TODO(asaf): Do we need to delete existing snippets in this case??? Not entirely sure how to trigger this through discord
|
||||
return nil
|
||||
}
|
||||
|
@ -904,42 +735,43 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
if shouldCreate {
|
||||
// Get an asset ID or URL to make a snippet from
|
||||
assetId, url, err := getSnippetAssetOrUrl(ctx, tx, &interned.Message)
|
||||
if assetId != nil || url != nil {
|
||||
contentMarkdown := interned.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
|
||||
contentMarkdown := interned.MessageContent.LastContent
|
||||
contentHTML := parsing.ParseMarkdown(contentMarkdown, parsing.DiscordMarkdown)
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
url,
|
||||
interned.Message.SentAt,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
assetId,
|
||||
interned.Message.ID,
|
||||
interned.HMNUser.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create snippet from attachment")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
INSERT INTO snippet (url, "when", description, _description_html, asset_id, discord_message_id, owner_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
`,
|
||||
url,
|
||||
interned.Message.SentAt,
|
||||
contentMarkdown,
|
||||
contentHTML,
|
||||
assetId,
|
||||
interned.Message.ID,
|
||||
interned.HMNUser.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to create snippet from attachment")
|
||||
}
|
||||
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch newly-created snippet")
|
||||
}
|
||||
|
||||
existingSnippet, err = FetchSnippetForMessage(ctx, tx, interned.Message.ID)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to fetch newly-created snippet")
|
||||
}
|
||||
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE discord_message
|
||||
SET snippet_created = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to mark message as having snippet")
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE discord_message
|
||||
SET snippet_created = TRUE
|
||||
WHERE id = $1
|
||||
`,
|
||||
interned.Message.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return oops.New(err, "failed to mark message as having snippet")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -995,8 +827,6 @@ func HandleSnippetForInternedMessage(ctx context.Context, dbConn db.ConnOrTx, in
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
hmndata.UpdateSnippetLastPostedForAllProjects(ctx, tx)
|
||||
}
|
||||
|
||||
err = tx.Commit(ctx)
|
||||
|
@ -1083,36 +913,3 @@ func messageHasLinks(content string) bool {
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
func messageShouldBeStored(msg *Message) bool {
|
||||
if msg == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(msg.Content)), "!til") {
|
||||
return true
|
||||
}
|
||||
|
||||
autostore := false
|
||||
for _, cid := range autostoreChannels {
|
||||
if msg.ChannelID == cid {
|
||||
autostore = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if autostore {
|
||||
hasGoodContent := true
|
||||
if msg.OriginalHasFields("content") && !messageHasLinks(msg.Content) {
|
||||
hasGoodContent = false
|
||||
}
|
||||
|
||||
hasGoodAttachments := true
|
||||
if msg.OriginalHasFields("attachments") && len(msg.Attachments) == 0 {
|
||||
hasGoodAttachments = false
|
||||
}
|
||||
|
||||
return hasGoodContent || hasGoodAttachments
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
|
|
@ -244,38 +244,20 @@ const (
|
|||
MessageTypeGuildInviteReminder MessageType = 22
|
||||
)
|
||||
|
||||
type MessageFlags int
|
||||
|
||||
const (
|
||||
MessageFlagCrossposted MessageFlags = 1 << iota
|
||||
MessageFlagIsCrosspost
|
||||
MessageFlagSuppressEmbeds
|
||||
MessageFlagSourceMessageDeleted
|
||||
MessageFlagUrgent
|
||||
MessageFlagHasThread
|
||||
MessageFlagEphemeral
|
||||
MessageFlagLoading
|
||||
MessageFlagFailedToMentionSomeRolesInThread
|
||||
MessageFlagSuppressNotifications
|
||||
MessageFlagIsVoiceMessage
|
||||
)
|
||||
|
||||
// https://discord.com/developers/docs/resources/channel#message-object
|
||||
type Message struct {
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
GuildID *string `json:"guild_id"`
|
||||
Content string `json:"content"`
|
||||
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
||||
Timestamp string `json:"timestamp"`
|
||||
Type MessageType `json:"type"`
|
||||
Flags MessageFlags `json:"flags"`
|
||||
ID string `json:"id"`
|
||||
ChannelID string `json:"channel_id"`
|
||||
GuildID *string `json:"guild_id"`
|
||||
Content string `json:"content"`
|
||||
Author *User `json:"author"` // note that this may not be an actual valid user (see the docs)
|
||||
Timestamp string `json:"timestamp"`
|
||||
Type MessageType `json:"type"`
|
||||
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
Embeds []Embed `json:"embeds"`
|
||||
|
||||
originalMap map[string]interface{}
|
||||
Backfilled bool
|
||||
}
|
||||
|
||||
func (m *Message) JumpURL() string {
|
||||
|
@ -335,7 +317,6 @@ func MessageFromMap(m interface{}, k string) *Message {
|
|||
Author: UserFromMap(m, "author"),
|
||||
Timestamp: maybeString(mmap, "timestamp"),
|
||||
Type: MessageType(maybeInt(mmap, "type")),
|
||||
Flags: MessageFlags(maybeInt(mmap, "flags")),
|
||||
|
||||
originalMap: mmap,
|
||||
}
|
||||
|
@ -1022,11 +1003,3 @@ func maybeBoolP(m map[string]interface{}, k string) *bool {
|
|||
boolval := val.(bool)
|
||||
return &boolval
|
||||
}
|
||||
|
||||
func maybeArray(m map[string]any, k string) []any {
|
||||
val, ok := m[k]
|
||||
if !ok || val == nil {
|
||||
return nil
|
||||
}
|
||||
return val.([]any)
|
||||
}
|
||||
|
|
|
@ -48,47 +48,7 @@ func SendRegistrationEmail(
|
|||
perf.EndBlock()
|
||||
|
||||
perf.StartBlock("EMAIL", "Sending email")
|
||||
err = sendMail(toAddress, toName, "[Handmade Network] Registration confirmation", contents)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to send email")
|
||||
}
|
||||
perf.EndBlock()
|
||||
|
||||
perf.EndBlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ExistingAccountEmailData struct {
|
||||
Name string
|
||||
Username string
|
||||
HomepageUrl string
|
||||
LoginUrl string
|
||||
}
|
||||
|
||||
func SendExistingAccountEmail(
|
||||
toAddress string,
|
||||
toName string,
|
||||
username string,
|
||||
destination string,
|
||||
perf *perf.RequestPerf,
|
||||
) error {
|
||||
perf.StartBlock("EMAIL", "Existing account email")
|
||||
|
||||
perf.StartBlock("EMAIL", "Rendering template")
|
||||
contents, err := renderTemplate("email_account_existing.html", ExistingAccountEmailData{
|
||||
Name: toName,
|
||||
Username: username,
|
||||
HomepageUrl: hmnurl.BuildHomepage(),
|
||||
LoginUrl: hmnurl.BuildLoginPage(destination),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
perf.EndBlock()
|
||||
|
||||
perf.StartBlock("EMAIL", "Sending email")
|
||||
err = sendMail(toAddress, toName, "[Handmade Network] You already have an account!", contents)
|
||||
err = sendMail(toAddress, toName, "[handmade.network] Registration confirmation", contents)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to send email")
|
||||
}
|
||||
|
@ -120,7 +80,7 @@ func SendPasswordReset(toAddress string, toName string, username string, resetTo
|
|||
perf.EndBlock()
|
||||
|
||||
perf.StartBlock("EMAIL", "Sending email")
|
||||
err = sendMail(toAddress, toName, "[Handmade Network] Your password reset request", contents)
|
||||
err = sendMail(toAddress, toName, "[handmade.network] Your password reset request", contents)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to send email")
|
||||
}
|
||||
|
@ -136,12 +96,12 @@ type TimeMachineEmailData struct {
|
|||
Username string
|
||||
UserEmail string
|
||||
DiscordUsername string
|
||||
MediaUrls []string
|
||||
MediaUrl string
|
||||
DeviceInfo string
|
||||
Description string
|
||||
}
|
||||
|
||||
func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername string, mediaUrls []string, deviceInfo, description string, perf *perf.RequestPerf) error {
|
||||
func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername, mediaUrl, deviceInfo, description string, perf *perf.RequestPerf) error {
|
||||
perf.StartBlock("EMAIL", "Time machine email")
|
||||
defer perf.EndBlock()
|
||||
|
||||
|
@ -150,7 +110,7 @@ func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername strin
|
|||
Username: username,
|
||||
UserEmail: userEmail,
|
||||
DiscordUsername: discordUsername,
|
||||
MediaUrls: mediaUrls,
|
||||
MediaUrl: mediaUrl,
|
||||
DeviceInfo: deviceInfo,
|
||||
Description: description,
|
||||
})
|
||||
|
@ -158,7 +118,7 @@ func SendTimeMachineEmail(profileUrl, username, userEmail, discordUsername strin
|
|||
return err
|
||||
}
|
||||
|
||||
err = sendMail("team@handmade.network", "HMN Team", "[Time Machine] New submission", contents)
|
||||
err = sendMail("team@handmade.network", "HMN Team", "[time machine] New submission", contents)
|
||||
if err != nil {
|
||||
return oops.New(err, "Failed to send email")
|
||||
}
|
||||
|
|
|
@ -129,7 +129,7 @@ func FetchEmbed(ctx context.Context, urlStr string, httpClient *http.Client, max
|
|||
filename := ""
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil {
|
||||
lastSlash := utils.Max(strings.LastIndex(u.Path, "/"), 0)
|
||||
lastSlash := utils.IntMax(strings.LastIndex(u.Path, "/"), 0)
|
||||
filename = u.Path[lastSlash:]
|
||||
}
|
||||
result := Embed{
|
||||
|
|
|
@ -17,9 +17,8 @@ type Event struct {
|
|||
|
||||
type Jam struct {
|
||||
Event
|
||||
Name string
|
||||
Slug string
|
||||
UrlSlug string
|
||||
Name string
|
||||
Slug string
|
||||
}
|
||||
|
||||
var WRJ2021 = Jam{
|
||||
|
@ -27,9 +26,8 @@ var WRJ2021 = Jam{
|
|||
StartTime: time.Date(2021, 9, 27, 0, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2021, 10, 4, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Name: "Wheel Reinvention Jam 2021",
|
||||
Slug: "WRJ2021",
|
||||
UrlSlug: "2021",
|
||||
Name: "Wheel Reinvention Jam 2021",
|
||||
Slug: "WRJ2021",
|
||||
}
|
||||
|
||||
var WRJ2022 = Jam{
|
||||
|
@ -37,9 +35,8 @@ var WRJ2022 = Jam{
|
|||
StartTime: time.Date(2022, 8, 15, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
EndTime: time.Date(2022, 8, 22, 8, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
},
|
||||
Name: "Wheel Reinvention Jam 2022",
|
||||
Slug: "WRJ2022",
|
||||
UrlSlug: "2022",
|
||||
Name: "Wheel Reinvention Jam 2022",
|
||||
Slug: "WRJ2022",
|
||||
}
|
||||
|
||||
var VJ2023 = Jam{
|
||||
|
@ -47,45 +44,10 @@ var VJ2023 = Jam{
|
|||
StartTime: time.Date(2023, 4, 14, 0, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2023, 4, 17, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Name: "Visibility Jam 2023",
|
||||
Slug: "VJ2023",
|
||||
UrlSlug: "visibility-2023",
|
||||
Name: "Visibility Jam 2023",
|
||||
Slug: "VJ2023",
|
||||
}
|
||||
|
||||
var WRJ2023 = Jam{
|
||||
Event: Event{
|
||||
StartTime: time.Date(2023, 9, 25, 10, 0, 0, 0, utils.Must1(time.LoadLocation("Europe/London"))),
|
||||
EndTime: time.Date(2023, 10, 1, 20, 0, 0, 0, utils.Must1(time.LoadLocation("Europe/London"))),
|
||||
},
|
||||
Name: "Wheel Reinvention Jam 2023",
|
||||
Slug: "WRJ2023",
|
||||
UrlSlug: "2023",
|
||||
}
|
||||
|
||||
var LJ2024 = Jam{
|
||||
Event: Event{
|
||||
StartTime: time.Date(2024, 8, 15, 17, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 8, 25, 0, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Name: "Learning Jam 2024",
|
||||
Slug: "LJ2024",
|
||||
UrlSlug: "learning-2024",
|
||||
}
|
||||
|
||||
var VJ2024 = Jam{
|
||||
// Trying looser times this year.
|
||||
// Start: 6am Seattle / 8am Minneapolis / 1pm UTC / 2pm London / 4pm Jerusalem
|
||||
// End: 10pm Seattle / 12am Minneapolis / 5am UTC / 6am London / 8am Jerusalem
|
||||
Event: Event{
|
||||
StartTime: time.Date(2024, 7, 19, 13, 0, 0, 0, time.UTC),
|
||||
EndTime: time.Date(2024, 7, 22, 5, 0, 0, 0, time.UTC),
|
||||
},
|
||||
Name: "Visibility Jam 2024",
|
||||
Slug: "VJ2024",
|
||||
UrlSlug: "visibility-2024",
|
||||
}
|
||||
|
||||
// Conferences
|
||||
var HMS2022 = Event{
|
||||
StartTime: time.Date(2022, 11, 16, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
EndTime: time.Date(2022, 11, 18, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
|
@ -101,17 +63,7 @@ var HMBoston2023 = Event{
|
|||
EndTime: time.Date(2023, 8, 4, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
}
|
||||
|
||||
var HMS2024 = Event{
|
||||
StartTime: time.Date(2024, 11, 20, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
EndTime: time.Date(2024, 11, 22, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
}
|
||||
|
||||
var HMBoston2024 = Event{
|
||||
StartTime: time.Date(2024, 8, 9, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
EndTime: time.Date(2024, 8, 10, 0, 0, 0, 0, utils.Must1(time.LoadLocation("America/Los_Angeles"))),
|
||||
}
|
||||
|
||||
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023, WRJ2023, LJ2024, VJ2024}
|
||||
var AllJams = []Jam{WRJ2021, WRJ2022, VJ2023}
|
||||
|
||||
func CurrentJam() *Jam {
|
||||
now := time.Now()
|
||||
|
@ -123,17 +75,6 @@ func CurrentJam() *Jam {
|
|||
return nil
|
||||
}
|
||||
|
||||
func PreviousJam() *Jam {
|
||||
now := time.Now()
|
||||
var mostRecent *Jam
|
||||
for i, jam := range AllJams {
|
||||
if jam.EndTime.Before(now) {
|
||||
mostRecent = &AllJams[i]
|
||||
}
|
||||
}
|
||||
return mostRecent
|
||||
}
|
||||
|
||||
func JamBySlug(slug string) Jam {
|
||||
for _, jam := range AllJams {
|
||||
if jam.Slug == slug {
|
||||
|
|
|
@ -23,7 +23,6 @@ type ProjectsQuery struct {
|
|||
// are generally visible to all users.
|
||||
Lifecycles []models.ProjectLifecycle // If empty, defaults to visible lifecycles. Do not conflate this with permissions; those are checked separately.
|
||||
Types ProjectTypeQuery // bitfield
|
||||
FeaturedOnly bool
|
||||
IncludeHidden bool
|
||||
|
||||
// Ignored when using FetchProject
|
||||
|
@ -39,9 +38,8 @@ type ProjectsQuery struct {
|
|||
|
||||
type ProjectAndStuff struct {
|
||||
Project models.Project
|
||||
LogoLightAsset *models.Asset
|
||||
LogoDarkAsset *models.Asset
|
||||
HeaderImage *models.Asset
|
||||
LogoLightAsset *models.Asset `db:"logolight_asset"`
|
||||
LogoDarkAsset *models.Asset `db:"logodark_asset"`
|
||||
Owners []*models.User
|
||||
Tag *models.Tag
|
||||
}
|
||||
|
@ -74,7 +72,6 @@ func FetchProjects(
|
|||
Project models.Project `db:"project"`
|
||||
LogoLightAsset *models.Asset `db:"logolight_asset"`
|
||||
LogoDarkAsset *models.Asset `db:"logodark_asset"`
|
||||
HeaderAsset *models.Asset `db:"header_asset"`
|
||||
Tag *models.Tag `db:"tag"`
|
||||
}
|
||||
|
||||
|
@ -89,7 +86,6 @@ func FetchProjects(
|
|||
project
|
||||
LEFT JOIN asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id
|
||||
LEFT JOIN asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
|
||||
LEFT JOIN asset AS header_asset ON header_asset.id = project.header_asset_id
|
||||
LEFT JOIN tag ON project.tag = tag.id
|
||||
`)
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
|
@ -134,9 +130,6 @@ func FetchProjects(
|
|||
}
|
||||
qb.Add(`)`)
|
||||
}
|
||||
if q.FeaturedOnly {
|
||||
qb.Add(`AND project.featured`)
|
||||
}
|
||||
if !q.IncludeHidden {
|
||||
qb.Add(`AND NOT project.hidden`)
|
||||
}
|
||||
|
@ -226,7 +219,6 @@ func FetchProjects(
|
|||
Project: p.Project,
|
||||
LogoLightAsset: p.LogoLightAsset,
|
||||
LogoDarkAsset: p.LogoDarkAsset,
|
||||
HeaderImage: p.HeaderAsset,
|
||||
Owners: owners,
|
||||
Tag: p.Tag,
|
||||
})
|
||||
|
@ -526,20 +518,3 @@ func SetProjectTag(
|
|||
|
||||
return resultTag, nil
|
||||
}
|
||||
|
||||
func UpdateSnippetLastPostedForAllProjects(ctx context.Context, dbConn db.ConnOrTx) error {
|
||||
_, err := dbConn.Exec(ctx,
|
||||
`
|
||||
UPDATE project p SET (snippet_last_posted, all_last_updated) = (
|
||||
SELECT
|
||||
COALESCE(MAX(s."when"), 'epoch'),
|
||||
GREATEST(p.forum_last_updated, p.blog_last_updated, p.annotation_last_updated, MAX(s."when"))
|
||||
FROM
|
||||
snippet s
|
||||
JOIN snippet_project sp ON s.id = sp.snippet_id
|
||||
WHERE sp.project_id = p.id
|
||||
)
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
|
|
@ -16,8 +16,6 @@ type SnippetQuery struct {
|
|||
Tags []int
|
||||
DiscordMessageIDs []string
|
||||
|
||||
FeaturedOnly bool
|
||||
|
||||
Limit, Offset int // if empty, no pagination
|
||||
}
|
||||
|
||||
|
@ -46,8 +44,6 @@ func FetchSnippets(
|
|||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
isFiltering := len(q.IDs) > 0 || len(q.Tags) > 0 || len(q.ProjectIDs) > 0
|
||||
|
||||
var tagSnippetIDs []int
|
||||
if len(q.Tags) > 0 {
|
||||
// Get snippet IDs with this tag, then use that in the main query
|
||||
|
@ -66,6 +62,11 @@ func FetchSnippets(
|
|||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
||||
}
|
||||
|
||||
// special early-out: no snippets found for these tags at all
|
||||
if len(snippetIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tagSnippetIDs = snippetIDs
|
||||
}
|
||||
|
||||
|
@ -86,6 +87,11 @@ func FetchSnippets(
|
|||
return nil, oops.New(err, "failed to get snippet IDs for tag")
|
||||
}
|
||||
|
||||
// special early-out: no snippets found for these projects at all
|
||||
if len(snippetIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
projectSnippetIDs = snippetIDs
|
||||
}
|
||||
|
||||
|
@ -103,24 +109,17 @@ func FetchSnippets(
|
|||
TRUE
|
||||
`,
|
||||
)
|
||||
allSnippetIDs := make([]int, 0, len(q.IDs)+len(tagSnippetIDs)+len(projectSnippetIDs))
|
||||
allSnippetIDs = append(allSnippetIDs, q.IDs...)
|
||||
allSnippetIDs = append(allSnippetIDs, tagSnippetIDs...)
|
||||
allSnippetIDs = append(allSnippetIDs, projectSnippetIDs...)
|
||||
if isFiltering && len(allSnippetIDs) == 0 {
|
||||
// We already managed to filter out all snippets, and all further
|
||||
// parts of this query are more filters, so we can just fail everything
|
||||
// else from right here.
|
||||
qb.Add(`AND FALSE`)
|
||||
} else if len(allSnippetIDs) > 0 && len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND (snippet.id = ANY ($?) OR snippet.owner_id = ANY ($?))`, allSnippetIDs, q.OwnerIDs)
|
||||
} else {
|
||||
if len(allSnippetIDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, allSnippetIDs)
|
||||
}
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||
}
|
||||
if len(q.IDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
|
||||
}
|
||||
if len(tagSnippetIDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, tagSnippetIDs)
|
||||
}
|
||||
if len(projectSnippetIDs) > 0 {
|
||||
qb.Add(`AND snippet.id = ANY ($?)`, projectSnippetIDs)
|
||||
}
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||
}
|
||||
if len(q.DiscordMessageIDs) > 0 {
|
||||
qb.Add(`AND snippet.discord_message_id = ANY ($?)`, q.DiscordMessageIDs)
|
||||
|
|
|
@ -375,15 +375,11 @@ func FetchPosts(
|
|||
models.VisibleProjectLifecycles,
|
||||
models.HMNProjectID,
|
||||
)
|
||||
if len(q.ProjectIDs) > 0 && len(q.UserIDs) > 0 {
|
||||
qb.Add(`AND (project.id = ANY($?) OR post.author_id = ANY($?))`, q.ProjectIDs, q.UserIDs)
|
||||
} else {
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
||||
}
|
||||
if len(q.UserIDs) > 0 {
|
||||
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
|
||||
}
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
||||
}
|
||||
if len(q.UserIDs) > 0 {
|
||||
qb.Add(`AND post.author_id = ANY ($?)`, q.UserIDs)
|
||||
}
|
||||
if len(q.ThreadIDs) > 0 {
|
||||
qb.Add(`AND post.thread_id = ANY ($?)`, q.ThreadIDs)
|
||||
|
|
|
@ -1,327 +0,0 @@
|
|||
package hmndata
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
)
|
||||
|
||||
type TimelineQuery struct {
|
||||
OwnerIDs []int
|
||||
ProjectIDs []int
|
||||
|
||||
SkipSnippets bool
|
||||
SkipPosts bool
|
||||
|
||||
IncludePostDescription bool
|
||||
|
||||
Limit, Offset int
|
||||
}
|
||||
|
||||
type TimelineItemAndStuff struct {
|
||||
Item models.TimelineItem `db:"item"`
|
||||
Owner *models.User `db:"owner"`
|
||||
AvatarAsset *models.Asset `db:"avatar"`
|
||||
Asset *models.Asset `db:"asset"`
|
||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
Projects []*ProjectAndStuff
|
||||
}
|
||||
|
||||
func FetchTimeline(
|
||||
ctx context.Context,
|
||||
dbConn db.ConnOrTx,
|
||||
currentUser *models.User,
|
||||
q TimelineQuery,
|
||||
) ([]*TimelineItemAndStuff, error) {
|
||||
perf := perf.ExtractPerf(ctx)
|
||||
perf.StartBlock("SQL", "Fetch timeline")
|
||||
defer perf.EndBlock()
|
||||
|
||||
var qb db.QueryBuilder
|
||||
|
||||
currentUserId := -1
|
||||
if currentUser != nil {
|
||||
currentUserId = currentUser.ID
|
||||
}
|
||||
|
||||
currentUserIsAdmin := false
|
||||
if currentUser != nil {
|
||||
currentUserIsAdmin = currentUser.IsStaff
|
||||
}
|
||||
|
||||
itemSelects := []string{}
|
||||
if !q.SkipSnippets {
|
||||
itemSelects = append(itemSelects, "SELECT * from snippet_item")
|
||||
}
|
||||
if !q.SkipPosts {
|
||||
itemSelects = append(itemSelects, "SELECT * from post_item")
|
||||
}
|
||||
|
||||
if len(itemSelects) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
itemSelect := strings.Join(itemSelects, " UNION ALL ")
|
||||
|
||||
qb.Add(
|
||||
`
|
||||
WITH visible_project AS (
|
||||
SELECT *
|
||||
FROM project
|
||||
WHERE
|
||||
$? = true
|
||||
OR project.id = $?
|
||||
OR (SELECT count(*) > 0 FROM user_project WHERE project_id = project.id AND user_id = $?)
|
||||
OR (
|
||||
project.lifecycle = ANY($?)
|
||||
AND NOT project.hidden
|
||||
AND (SELECT every(hmn_user.status = $?)
|
||||
FROM user_project
|
||||
JOIN hmn_user ON hmn_user.id = user_project.user_id
|
||||
WHERE user_project.project_id = project.id
|
||||
)
|
||||
)
|
||||
),`,
|
||||
currentUserIsAdmin,
|
||||
models.HMNProjectID,
|
||||
currentUserId,
|
||||
models.VisibleProjectLifecycles,
|
||||
models.UserStatusApproved,
|
||||
)
|
||||
qb.Add(
|
||||
`
|
||||
snippet_item AS (
|
||||
SELECT id,
|
||||
"when",
|
||||
'snippet' AS timeline_type,
|
||||
owner_id,
|
||||
'' AS title,
|
||||
_description_html AS parsed_desc,
|
||||
description AS raw_desc,
|
||||
asset_id,
|
||||
discord_message_id,
|
||||
url,
|
||||
0 AS project_id,
|
||||
0 AS thread_id,
|
||||
0 AS subforum_id,
|
||||
0 AS thread_type,
|
||||
TRUE AS first_post
|
||||
FROM snippet
|
||||
WHERE TRUE
|
||||
`,
|
||||
)
|
||||
if len(q.ProjectIDs)+len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`AND (`)
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
qb.Add(
|
||||
`
|
||||
(
|
||||
SELECT count(*)
|
||||
FROM snippet_project
|
||||
WHERE
|
||||
snippet_project.snippet_id = snippet.id
|
||||
AND
|
||||
snippet_project.project_id = ANY($?)
|
||||
) > 0
|
||||
`,
|
||||
q.ProjectIDs,
|
||||
)
|
||||
} else {
|
||||
qb.Add("FALSE")
|
||||
}
|
||||
qb.Add(" OR ")
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`owner_id = ANY($?)`, q.OwnerIDs)
|
||||
} else {
|
||||
qb.Add("FALSE")
|
||||
}
|
||||
qb.Add(`)`)
|
||||
}
|
||||
qb.Add(
|
||||
`
|
||||
),
|
||||
post_item AS (
|
||||
SELECT post.id,
|
||||
postdate AS "when",
|
||||
'post' AS timeline_type,
|
||||
author_id AS owner_id,
|
||||
thread.title AS title,
|
||||
`,
|
||||
)
|
||||
if q.IncludePostDescription {
|
||||
qb.Add(
|
||||
`
|
||||
post_version.text_parsed AS parsed_desc,
|
||||
post_version.text_raw AS raw_desc,
|
||||
`,
|
||||
)
|
||||
} else {
|
||||
qb.Add(
|
||||
`
|
||||
'' AS parsed_desc,
|
||||
'' AS raw_desc,
|
||||
`,
|
||||
)
|
||||
}
|
||||
qb.Add(
|
||||
`
|
||||
NULL::uuid AS asset_id,
|
||||
NULL AS discord_message_id,
|
||||
NULL AS url,
|
||||
post.project_id,
|
||||
thread_id,
|
||||
subforum_id,
|
||||
thread_type,
|
||||
(post.id = thread.first_id) AS first_post
|
||||
FROM post
|
||||
JOIN thread ON thread.id = thread_id
|
||||
JOIN post_version ON post_version.id = current_id
|
||||
JOIN visible_project ON visible_project.id = post.project_id
|
||||
WHERE
|
||||
post.deleted = false AND thread.deleted = false
|
||||
`,
|
||||
)
|
||||
if len(q.OwnerIDs)+len(q.ProjectIDs) > 0 {
|
||||
qb.Add(`AND (`)
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
qb.Add(`post.project_id = ANY($?)`, q.ProjectIDs)
|
||||
} else {
|
||||
qb.Add("FALSE")
|
||||
}
|
||||
qb.Add(" OR ")
|
||||
if len(q.OwnerIDs) > 0 {
|
||||
qb.Add(`post.author_id = ANY($?)`, q.OwnerIDs)
|
||||
} else {
|
||||
qb.Add("FALSE")
|
||||
}
|
||||
qb.Add(`)`)
|
||||
}
|
||||
qb.Add(
|
||||
`
|
||||
),
|
||||
item AS (
|
||||
`,
|
||||
)
|
||||
qb.Add(itemSelect)
|
||||
qb.Add(
|
||||
`
|
||||
)
|
||||
SELECT $columns FROM item
|
||||
LEFT JOIN thread ON thread.id = thread_id
|
||||
LEFT JOIN hmn_user AS owner ON owner_id = owner.id
|
||||
LEFT JOIN asset AS avatar ON avatar.id = owner.avatar_asset_id
|
||||
LEFT JOIN asset ON asset_id = asset.id
|
||||
LEFT JOIN discord_message ON discord_message_id = discord_message.id
|
||||
WHERE TRUE
|
||||
`,
|
||||
)
|
||||
|
||||
if currentUser == nil {
|
||||
qb.Add(
|
||||
`AND owner.status = $? -- snippet owner is Approved`,
|
||||
models.UserStatusApproved,
|
||||
)
|
||||
} else if !currentUser.IsStaff {
|
||||
qb.Add(
|
||||
`
|
||||
AND (
|
||||
owner.status = $? -- snippet owner is Approved
|
||||
OR owner.id = $? -- current user is the snippet owner
|
||||
)
|
||||
`,
|
||||
models.UserStatusApproved,
|
||||
currentUser.ID,
|
||||
)
|
||||
}
|
||||
|
||||
qb.Add(
|
||||
`
|
||||
ORDER BY "when" DESC
|
||||
`,
|
||||
)
|
||||
if q.Limit > 0 {
|
||||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||
}
|
||||
|
||||
perf.StartBlock("SQL", "Query timeline")
|
||||
results, err := db.Query[TimelineItemAndStuff](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
perf.EndBlock()
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch timeline items")
|
||||
}
|
||||
|
||||
for idx := range results {
|
||||
if results[idx].Owner != nil {
|
||||
results[idx].Owner.AvatarAsset = results[idx].AvatarAsset
|
||||
}
|
||||
}
|
||||
|
||||
perf.StartBlock("TIMELINE", "Fixup projects")
|
||||
defer perf.EndBlock()
|
||||
projectsSeen := make(map[int]bool)
|
||||
var projectIds []int
|
||||
var snippetIds []int
|
||||
projectTargets := make(map[int][]*TimelineItemAndStuff)
|
||||
snippetItems := make(map[int]*TimelineItemAndStuff)
|
||||
for _, r := range results {
|
||||
if r.Item.ProjectID != 0 {
|
||||
if _, found := projectsSeen[r.Item.ProjectID]; !found {
|
||||
projectIds = append(projectIds, r.Item.ProjectID)
|
||||
projectsSeen[r.Item.ProjectID] = true
|
||||
}
|
||||
|
||||
projectTargets[r.Item.ProjectID] = append(projectTargets[r.Item.ProjectID], r)
|
||||
}
|
||||
if r.Item.Type == models.TimelineItemTypeSnippet {
|
||||
snippetIds = append(snippetIds, r.Item.ID)
|
||||
snippetItems[r.Item.ID] = r
|
||||
}
|
||||
}
|
||||
|
||||
type snippetProjectRow struct {
|
||||
SnippetID int `db:"snippet_id"`
|
||||
ProjectID int `db:"project_id"`
|
||||
}
|
||||
perf.StartBlock("SQL", "Fetch snippet projects")
|
||||
snippetProjects, err := db.Query[snippetProjectRow](ctx, dbConn,
|
||||
`
|
||||
SELECT $columns
|
||||
FROM snippet_project
|
||||
WHERE snippet_id = ANY($1)
|
||||
`,
|
||||
snippetIds,
|
||||
)
|
||||
perf.EndBlock()
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch project ids for timeline")
|
||||
}
|
||||
|
||||
for _, sp := range snippetProjects {
|
||||
if _, found := projectsSeen[sp.ProjectID]; !found {
|
||||
projectIds = append(projectIds, sp.ProjectID)
|
||||
projectsSeen[sp.ProjectID] = true
|
||||
}
|
||||
projectTargets[sp.ProjectID] = append(projectTargets[sp.ProjectID], snippetItems[sp.SnippetID])
|
||||
}
|
||||
|
||||
projects, err := FetchProjects(ctx, dbConn, currentUser, ProjectsQuery{
|
||||
ProjectIDs: projectIds,
|
||||
IncludeHidden: true,
|
||||
Lifecycles: models.AllProjectLifecycles,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch projects for timeline")
|
||||
}
|
||||
for pIdx := range projects {
|
||||
targets := projectTargets[projects[pIdx].Project.ID]
|
||||
for _, t := range targets {
|
||||
t.Projects = append(t.Projects, &projects[pIdx])
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
|
@ -8,7 +8,6 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"git.handmade.network/hmn/hmn/src/perf"
|
||||
)
|
||||
|
||||
const InvalidUserTwitchID = "INVALID_USER"
|
||||
|
@ -22,58 +21,27 @@ type TwitchStreamer struct {
|
|||
|
||||
var twitchRegex = regexp.MustCompile(`twitch\.tv/(?P<login>[^/]+)$`)
|
||||
|
||||
type TwitchStreamersQuery struct {
|
||||
UserIDs []int
|
||||
ProjectIDs []int
|
||||
}
|
||||
|
||||
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx, q TwitchStreamersQuery) ([]TwitchStreamer, error) {
|
||||
perf := perf.ExtractPerf(ctx)
|
||||
perf.StartBlock("SQL", "Fetch twitch streamers")
|
||||
defer perf.EndBlock()
|
||||
var qb db.QueryBuilder
|
||||
qb.Add(
|
||||
func FetchTwitchStreamers(ctx context.Context, dbConn db.ConnOrTx) ([]TwitchStreamer, error) {
|
||||
dbStreamers, err := db.Query[models.Link](ctx, dbConn,
|
||||
`
|
||||
SELECT $columns{link}
|
||||
FROM
|
||||
link
|
||||
LEFT JOIN hmn_user AS link_owner ON link_owner.id = link.user_id
|
||||
WHERE
|
||||
TRUE
|
||||
`,
|
||||
)
|
||||
if len(q.UserIDs) > 0 && len(q.ProjectIDs) > 0 {
|
||||
qb.Add(
|
||||
`AND (link.user_id = ANY ($?) OR link.project_id = ANY ($?))`,
|
||||
q.UserIDs,
|
||||
q.ProjectIDs,
|
||||
)
|
||||
} else {
|
||||
if len(q.UserIDs) > 0 {
|
||||
qb.Add(`AND link.user_id = ANY ($?)`, q.UserIDs)
|
||||
}
|
||||
if len(q.ProjectIDs) > 0 {
|
||||
qb.Add(`AND link.project_id = ANY ($?)`, q.ProjectIDs)
|
||||
}
|
||||
}
|
||||
|
||||
qb.Add(
|
||||
`
|
||||
AND url ~* 'twitch\.tv/([^/]+)$'
|
||||
AND ((link.user_id IS NOT NULL AND link_owner.status = $?) OR (link.project_id IS NOT NULL AND
|
||||
url ~* 'twitch\.tv/([^/]+)$' AND
|
||||
((link.user_id IS NOT NULL AND link_owner.status = $1) OR (link.project_id IS NOT NULL AND
|
||||
(SELECT COUNT(*)
|
||||
FROM
|
||||
user_project AS hup
|
||||
JOIN hmn_user AS project_owner ON project_owner.id = hup.user_id
|
||||
WHERE
|
||||
hup.project_id = link.project_id AND
|
||||
project_owner.status != $?
|
||||
project_owner.status != $1
|
||||
) = 0))
|
||||
`,
|
||||
models.UserStatusApproved,
|
||||
models.UserStatusApproved,
|
||||
)
|
||||
dbStreamers, err := db.Query[models.Link](ctx, dbConn, qb.String(), qb.Args()...)
|
||||
if err != nil {
|
||||
return nil, oops.New(err, "failed to fetch twitch links")
|
||||
}
|
||||
|
|
|
@ -40,6 +40,18 @@ func TestHomepage(t *testing.T) {
|
|||
AssertSubdomain(t, hero.BuildHomepage(), "hero")
|
||||
}
|
||||
|
||||
func TestShowcase(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildShowcase(), RegexShowcase, nil)
|
||||
}
|
||||
|
||||
func TestStreams(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildStreams(), RegexStreams, nil)
|
||||
}
|
||||
|
||||
func TestWhenIsIt(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildWhenIsIt(), RegexWhenIsIt, nil)
|
||||
}
|
||||
|
||||
func TestAtomFeed(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildAtomFeed(), RegexAtomFeed, nil)
|
||||
AssertRegexMatch(t, BuildAtomFeedForProjects(), RegexAtomFeed, map[string]string{"feedtype": "projects"})
|
||||
|
@ -63,7 +75,7 @@ func TestLoginWithDiscord(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestLogoutAction(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildLogoutAction(""), RegexLogout, nil)
|
||||
AssertRegexMatch(t, BuildLogoutAction(""), RegexLogoutAction, nil)
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
|
@ -88,6 +100,8 @@ func TestStaticPages(t *testing.T) {
|
|||
AssertRegexMatch(t, BuildManifesto(), RegexManifesto, nil)
|
||||
AssertRegexMatch(t, BuildAbout(), RegexAbout, nil)
|
||||
AssertRegexMatch(t, BuildFoundation(), RegexFoundation, nil)
|
||||
AssertRegexMatch(t, BuildStaffRole("test"), RegexStaffRole, nil)
|
||||
AssertRegexMatch(t, BuildStaffRolesIndex(), RegexStaffRolesIndex, nil)
|
||||
AssertRegexMatch(t, BuildCommunicationGuidelines(), RegexCommunicationGuidelines, nil)
|
||||
AssertRegexMatch(t, BuildContactPage(), RegexContactPage, nil)
|
||||
AssertRegexMatch(t, BuildMonthlyUpdatePolicy(), RegexMonthlyUpdatePolicy, nil)
|
||||
|
@ -128,7 +142,9 @@ func TestFeed(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestProjectIndex(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildProjectIndex(), RegexProjectIndex, nil)
|
||||
AssertRegexMatch(t, BuildProjectIndex(1), RegexProjectIndex, nil)
|
||||
AssertRegexMatch(t, BuildProjectIndex(2), RegexProjectIndex, map[string]string{"page": "2"})
|
||||
assert.Panics(t, func() { BuildProjectIndex(0) })
|
||||
}
|
||||
|
||||
func TestProjectNew(t *testing.T) {
|
||||
|
@ -320,6 +336,10 @@ func TestAssetUpload(t *testing.T) {
|
|||
AssertSubdomain(t, hero.BuildAssetUpload(), "hero")
|
||||
}
|
||||
|
||||
func TestProjectCSS(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildProjectCSS("000000"), RegexProjectCSS, nil)
|
||||
}
|
||||
|
||||
func TestMarkdownWorkerJS(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildMarkdownWorkerJS(), RegexMarkdownWorkerJS, nil)
|
||||
}
|
||||
|
@ -395,57 +415,6 @@ func TestJamRecap2023_Visibility(t *testing.T) {
|
|||
AssertSubdomain(t, BuildJamRecap2023_Visibility(), "")
|
||||
}
|
||||
|
||||
func TestJamIndex2023(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildJamIndex2023(), RegexJamIndex2023, nil)
|
||||
AssertSubdomain(t, BuildJamIndex2023(), "")
|
||||
}
|
||||
|
||||
func TestJamFeed2023(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildJamFeed2023(), RegexJamFeed2023, nil)
|
||||
AssertSubdomain(t, BuildJamFeed2023(), "")
|
||||
}
|
||||
|
||||
func TestJamIndex2024_Learning(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildJamIndex2024_Learning(), RegexJamIndex2024_Learning, nil)
|
||||
AssertSubdomain(t, BuildJamIndex2024_Learning(), "")
|
||||
}
|
||||
|
||||
func TestJamFeed2024_Learning(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildJamFeed2024_Learning(), RegexJamFeed2024_Learning, nil)
|
||||
AssertSubdomain(t, BuildJamFeed2024_Learning(), "")
|
||||
}
|
||||
|
||||
func TestTimeMachine(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTimeMachine(), RegexTimeMachine, nil)
|
||||
AssertSubdomain(t, BuildTimeMachine(), "")
|
||||
}
|
||||
|
||||
func TestTimeMachineSubmissions(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTimeMachineSubmissions(), RegexTimeMachineSubmissions, nil)
|
||||
AssertRegexMatch(t, BuildTimeMachineSubmission(123), RegexTimeMachineSubmissions, nil)
|
||||
AssertSubdomain(t, BuildTimeMachineSubmissions(), "")
|
||||
}
|
||||
|
||||
func TestTimeMachineAtomFeed(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTimeMachineAtomFeed(), RegexTimeMachineAtomFeed, nil)
|
||||
AssertSubdomain(t, BuildTimeMachineAtomFeed(), "")
|
||||
}
|
||||
|
||||
func TestTimeMachineForm(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTimeMachineForm(), RegexTimeMachineForm, nil)
|
||||
AssertSubdomain(t, BuildTimeMachineForm(), "")
|
||||
}
|
||||
|
||||
func TestTimeMachineFormDone(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildTimeMachineFormDone(), RegexTimeMachineFormDone, nil)
|
||||
AssertSubdomain(t, BuildTimeMachineFormDone(), "")
|
||||
}
|
||||
|
||||
func TestNewsletterSignup(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildAPINewsletterSignup(), RegexAPINewsletterSignup, nil)
|
||||
AssertSubdomain(t, BuildAPINewsletterSignup(), "")
|
||||
}
|
||||
|
||||
func TestProjectNewJam(t *testing.T) {
|
||||
AssertRegexMatch(t, BuildProjectNewJam(), RegexProjectNew, nil)
|
||||
AssertSubdomain(t, BuildProjectNewJam(), "")
|
||||
|
|
|
@ -28,6 +28,27 @@ func (c *UrlContext) BuildHomepage() string {
|
|||
return c.Url("/", nil)
|
||||
}
|
||||
|
||||
var RegexShowcase = regexp.MustCompile("^/showcase$")
|
||||
|
||||
func BuildShowcase() string {
|
||||
defer CatchPanic()
|
||||
return Url("/showcase", nil)
|
||||
}
|
||||
|
||||
var RegexStreams = regexp.MustCompile("^/streams$")
|
||||
|
||||
func BuildStreams() string {
|
||||
defer CatchPanic()
|
||||
return Url("/streams", nil)
|
||||
}
|
||||
|
||||
var RegexWhenIsIt = regexp.MustCompile("^/whenisit$")
|
||||
|
||||
func BuildWhenIsIt() string {
|
||||
defer CatchPanic()
|
||||
return Url("/whenisit", nil)
|
||||
}
|
||||
|
||||
var RegexJamsIndex = regexp.MustCompile("^/jams$")
|
||||
|
||||
func BuildJamsIndex() string {
|
||||
|
@ -56,27 +77,6 @@ func BuildJamIndex2022() string {
|
|||
return Url("/jam/2022", nil)
|
||||
}
|
||||
|
||||
var RegexJamFeed2022 = regexp.MustCompile("^/jam/2022/feed$")
|
||||
|
||||
func BuildJamFeed2022() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2022/feed", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2023 = regexp.MustCompile("^/jam/2023$")
|
||||
|
||||
func BuildJamIndex2023() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2023", nil)
|
||||
}
|
||||
|
||||
var RegexJamFeed2023 = regexp.MustCompile("^/jam/2023/feed$")
|
||||
|
||||
func BuildJamFeed2023() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/2023/feed", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2023_Visibility = regexp.MustCompile("^/jam/visibility-2023$")
|
||||
|
||||
func BuildJamIndex2023_Visibility() string {
|
||||
|
@ -98,44 +98,11 @@ func BuildJamRecap2023_Visibility() string {
|
|||
return Url("/jam/visibility-2023/recap", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2024_Learning = regexp.MustCompile("^/jam/learning-2024$")
|
||||
var RegexJamFeed2022 = regexp.MustCompile("^/jam/2022/feed$")
|
||||
|
||||
func BuildJamIndex2024_Learning() string {
|
||||
func BuildJamFeed2022() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/learning-2024", nil)
|
||||
}
|
||||
|
||||
var RegexJamFeed2024_Learning = regexp.MustCompile("^/jam/learning-2024/feed$")
|
||||
|
||||
func BuildJamFeed2024_Learning() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/learning-2024/feed", nil)
|
||||
}
|
||||
|
||||
var RegexJamGuidelines2024_Learning = regexp.MustCompile("^/jam/learning-2024/guidelines$")
|
||||
|
||||
func BuildJamGuidelines2024_Learning() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/learning-2024/guidelines", nil)
|
||||
}
|
||||
|
||||
var RegexJamIndex2024_Visibility = regexp.MustCompile("^/jam/visibility-2024$")
|
||||
|
||||
func BuildJamIndex2024_Visibility() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/visibility-2024", nil)
|
||||
}
|
||||
|
||||
var RegexJamSaveTheDate = regexp.MustCompile("^/jam/upcoming$")
|
||||
|
||||
func BuildJamSaveTheDate() string {
|
||||
defer CatchPanic()
|
||||
return Url("/jam/upcoming", nil)
|
||||
}
|
||||
|
||||
func BuildJamIndexAny(slug string) string {
|
||||
defer CatchPanic()
|
||||
return Url(fmt.Sprintf("/jam/%s", slug), nil)
|
||||
return Url("/jam/2022/feed", nil)
|
||||
}
|
||||
|
||||
var RegexTimeMachine = regexp.MustCompile("^/timemachine$")
|
||||
|
@ -145,25 +112,6 @@ func BuildTimeMachine() string {
|
|||
return Url("/timemachine", nil)
|
||||
}
|
||||
|
||||
var RegexTimeMachineSubmissions = regexp.MustCompile("^/timemachine/submissions$")
|
||||
|
||||
func BuildTimeMachineSubmissions() string {
|
||||
defer CatchPanic()
|
||||
return Url("/timemachine/submissions", nil)
|
||||
}
|
||||
|
||||
func BuildTimeMachineSubmission(id int) string {
|
||||
defer CatchPanic()
|
||||
return UrlWithFragment("/timemachine/submissions", nil, strconv.Itoa(id))
|
||||
}
|
||||
|
||||
var RegexTimeMachineAtomFeed = regexp.MustCompile("^/timemachine/submissions/atom$")
|
||||
|
||||
func BuildTimeMachineAtomFeed() string {
|
||||
defer CatchPanic()
|
||||
return Url("/timemachine/submissions/atom", nil)
|
||||
}
|
||||
|
||||
var RegexTimeMachineForm = regexp.MustCompile("^/timemachine/submit$")
|
||||
|
||||
func BuildTimeMachineForm() string {
|
||||
|
@ -178,20 +126,6 @@ func BuildTimeMachineFormDone() string {
|
|||
return Url("/timemachine/thanks", nil)
|
||||
}
|
||||
|
||||
var RegexCalendarIndex = regexp.MustCompile("^/calendar$")
|
||||
|
||||
func BuildCalendarIndex() string {
|
||||
defer CatchPanic()
|
||||
return Url("/calendar", nil)
|
||||
}
|
||||
|
||||
var RegexCalendarICal = regexp.MustCompile("^/Handmade Network.ical$")
|
||||
|
||||
func BuildCalendarICal() string {
|
||||
defer CatchPanic()
|
||||
return Url("/Handmade Network.ical", nil)
|
||||
}
|
||||
|
||||
// QUESTION(ben): Can we change these routes?
|
||||
|
||||
var RegexLoginAction = regexp.MustCompile("^/login$")
|
||||
|
@ -205,11 +139,7 @@ var RegexLoginPage = regexp.MustCompile("^/login$")
|
|||
|
||||
func BuildLoginPage(redirectTo string) string {
|
||||
defer CatchPanic()
|
||||
var q []Q
|
||||
if redirectTo != "" {
|
||||
q = append(q, Q{Name: "redirect", Value: redirectTo})
|
||||
}
|
||||
return Url("/login", q)
|
||||
return Url("/login", []Q{{Name: "redirect", Value: redirectTo}})
|
||||
}
|
||||
|
||||
var RegexLoginWithDiscord = regexp.MustCompile("^/login-with-discord$")
|
||||
|
@ -219,7 +149,7 @@ func BuildLoginWithDiscord(redirectTo string) string {
|
|||
return Url("/login-with-discord", []Q{{Name: "redirect", Value: redirectTo}})
|
||||
}
|
||||
|
||||
var RegexLogout = regexp.MustCompile("^/logout$")
|
||||
var RegexLogoutAction = regexp.MustCompile("^/logout$")
|
||||
|
||||
func BuildLogoutAction(redir string) string {
|
||||
defer CatchPanic()
|
||||
|
@ -340,6 +270,24 @@ func BuildConferences() string {
|
|||
return Url("/conferences", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Volunteer/Staff Roles
|
||||
*/
|
||||
|
||||
var RegexStaffRolesIndex = regexp.MustCompile(`^/roles$`)
|
||||
|
||||
func BuildStaffRolesIndex() string {
|
||||
defer CatchPanic()
|
||||
return Url("/roles", nil)
|
||||
}
|
||||
|
||||
var RegexStaffRole = regexp.MustCompile(`^/roles/(?P<slug>[^/]+)$`)
|
||||
|
||||
func BuildStaffRole(slug string) string {
|
||||
defer CatchPanic()
|
||||
return Url(fmt.Sprintf("/roles/%s", slug), nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* User
|
||||
*/
|
||||
|
@ -453,11 +401,18 @@ func BuildAtomFeedForShowcase() string {
|
|||
* Projects
|
||||
*/
|
||||
|
||||
var RegexProjectIndex = regexp.MustCompile(`^/projects$`)
|
||||
var RegexProjectIndex = regexp.MustCompile("^/projects(/(?P<page>.+)?)?$")
|
||||
|
||||
func BuildProjectIndex() string {
|
||||
func BuildProjectIndex(page int) string {
|
||||
defer CatchPanic()
|
||||
return Url("/projects", nil)
|
||||
if page < 1 {
|
||||
panic(oops.New(nil, "page must be >= 1"))
|
||||
}
|
||||
if page == 1 {
|
||||
return Url("/projects", nil)
|
||||
} else {
|
||||
return Url(fmt.Sprintf("/projects/%d", page), nil)
|
||||
}
|
||||
}
|
||||
|
||||
var RegexProjectNew = regexp.MustCompile("^/p/new$")
|
||||
|
@ -613,12 +568,6 @@ func BuildEducationRerender() string {
|
|||
return Url("/education/rerender", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Style test
|
||||
*/
|
||||
|
||||
var RegexStyleTest = regexp.MustCompile(`^/debug/styles$`)
|
||||
|
||||
/*
|
||||
* Forums
|
||||
*/
|
||||
|
@ -892,12 +841,6 @@ func BuildDiscordShowcaseBacklog() string {
|
|||
return Url("/discord_showcase_backlog", nil)
|
||||
}
|
||||
|
||||
var RegexDiscordBotDebugPage = regexp.MustCompile("^/discord_bot_debug$")
|
||||
|
||||
func BuildDiscordBotDebugPage() string {
|
||||
return Url("/discord_bot_debug", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* API
|
||||
*/
|
||||
|
@ -908,12 +851,6 @@ func BuildAPICheckUsername() string {
|
|||
return Url("/api/check_username", nil)
|
||||
}
|
||||
|
||||
var RegexAPINewsletterSignup = regexp.MustCompile("^/api/newsletter_signup$")
|
||||
|
||||
func BuildAPINewsletterSignup() string {
|
||||
return Url("/api/newsletter_signup", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* Twitch stuff
|
||||
*/
|
||||
|
@ -926,28 +863,6 @@ func BuildTwitchEventSubCallback() string {
|
|||
|
||||
var RegexTwitchDebugPage = regexp.MustCompile("^/twitch_debug$")
|
||||
|
||||
/*
|
||||
* Following
|
||||
*/
|
||||
|
||||
var RegexFollowingTest = regexp.MustCompile("^/following$")
|
||||
|
||||
func BuildFollowingTest() string {
|
||||
return Url("/following", nil)
|
||||
}
|
||||
|
||||
var RegexFollowUser = regexp.MustCompile("^/follow/user$")
|
||||
|
||||
func BuildFollowUser() string {
|
||||
return Url("/follow/user", nil)
|
||||
}
|
||||
|
||||
var RegexFollowProject = regexp.MustCompile("^/follow/project$")
|
||||
|
||||
func BuildFollowProject() string {
|
||||
return Url("/follow/project", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
* User assets
|
||||
*/
|
||||
|
@ -963,6 +878,13 @@ func (c *UrlContext) BuildAssetUpload() string {
|
|||
* Assets
|
||||
*/
|
||||
|
||||
var RegexProjectCSS = regexp.MustCompile("^/assets/project.css$")
|
||||
|
||||
func BuildProjectCSS(color string) string {
|
||||
defer CatchPanic()
|
||||
return Url("/assets/project.css", []Q{{"color", color}})
|
||||
}
|
||||
|
||||
var RegexMarkdownWorkerJS = regexp.MustCompile("^/assets/markdown_worker.js$")
|
||||
|
||||
func BuildMarkdownWorkerJS() string {
|
||||
|
@ -978,13 +900,6 @@ func BuildS3Asset(s3key string) string {
|
|||
return res
|
||||
}
|
||||
|
||||
var RegexEsBuild = regexp.MustCompile("^/esbuild$")
|
||||
|
||||
func BuildEsBuild() string {
|
||||
defer CatchPanic()
|
||||
return Url("/esbuild", nil)
|
||||
}
|
||||
|
||||
var RegexPublic = regexp.MustCompile("^/public/.+$")
|
||||
|
||||
func BuildPublic(filepath string, cachebust bool) string {
|
||||
|
@ -1032,12 +947,6 @@ func BuildUserFile(filepath string) string {
|
|||
return BuildPublic(fmt.Sprintf("media/%s", filepath), false)
|
||||
}
|
||||
|
||||
/*
|
||||
* Redirects
|
||||
*/
|
||||
|
||||
var RegexUnwind = regexp.MustCompile(`^/unwind$`)
|
||||
|
||||
/*
|
||||
* Other
|
||||
*/
|
||||
|
|
|
@ -1,94 +0,0 @@
|
|||
package links
|
||||
|
||||
import "regexp"
|
||||
|
||||
//
|
||||
// This is all in its own package so we can compile it to wasm without building extra junk.
|
||||
//
|
||||
|
||||
// An online site/service for which we recognize the link
|
||||
type Service struct {
|
||||
Name string
|
||||
IconName string
|
||||
Regex *regexp.Regexp
|
||||
}
|
||||
|
||||
var Services = []Service{
|
||||
// {
|
||||
// Name: "itch.io",
|
||||
// IconName: "itch",
|
||||
// Regex: regexp.MustCompile(`://(?P<username>[\w-]+)\.itch\.io`),
|
||||
// },
|
||||
{
|
||||
Name: "App Store",
|
||||
IconName: "app-store",
|
||||
Regex: regexp.MustCompile(`^https?://apps.apple.com`),
|
||||
},
|
||||
{
|
||||
Name: "Bluesky",
|
||||
IconName: "bluesky",
|
||||
Regex: regexp.MustCompile(`^https?://bsky.app/profile/(?P<username>[\w.-]+)$`),
|
||||
},
|
||||
{
|
||||
Name: "Discord",
|
||||
IconName: "discord",
|
||||
Regex: regexp.MustCompile(`^https?://discord\.gg`),
|
||||
},
|
||||
{
|
||||
Name: "GitHub",
|
||||
IconName: "github",
|
||||
Regex: regexp.MustCompile(`^https?://github\.com/(?P<username>[\w/-]+)`),
|
||||
},
|
||||
{
|
||||
Name: "GitLab",
|
||||
IconName: "gitlab",
|
||||
Regex: regexp.MustCompile(`^https?://gitlab\.com/(?P<username>[\w/-]+)`),
|
||||
},
|
||||
{
|
||||
Name: "Google Play",
|
||||
IconName: "google-play",
|
||||
Regex: regexp.MustCompile(`^https?://play\.google\.com`),
|
||||
},
|
||||
{
|
||||
Name: "Patreon",
|
||||
IconName: "patreon",
|
||||
Regex: regexp.MustCompile(`^https?://patreon\.com/(?P<username>[\w-]+)`),
|
||||
},
|
||||
{
|
||||
Name: "Twitch",
|
||||
IconName: "twitch",
|
||||
Regex: regexp.MustCompile(`^https?://twitch\.tv/(?P<username>[\w/-]+)`),
|
||||
},
|
||||
{
|
||||
Name: "Twitter",
|
||||
IconName: "twitter",
|
||||
Regex: regexp.MustCompile(`^https?://(twitter|x)\.com/(?P<username>\w+)`),
|
||||
},
|
||||
{
|
||||
Name: "Vimeo",
|
||||
IconName: "vimeo",
|
||||
Regex: regexp.MustCompile(`^https?://vimeo\.com/(?P<username>\w+)`),
|
||||
},
|
||||
{
|
||||
Name: "YouTube",
|
||||
IconName: "youtube",
|
||||
Regex: regexp.MustCompile(`youtube\.com/(c/)?(?P<username>[@\w/-]+)$`),
|
||||
},
|
||||
}
|
||||
|
||||
func ParseKnownServicesForUrl(url string) (service Service, username string) {
|
||||
for _, svc := range Services {
|
||||
match := svc.Regex.FindStringSubmatch(url)
|
||||
if match != nil {
|
||||
username := ""
|
||||
if idx := svc.Regex.SubexpIndex("username"); idx >= 0 {
|
||||
username = match[idx]
|
||||
}
|
||||
|
||||
return svc, username
|
||||
}
|
||||
}
|
||||
return Service{
|
||||
IconName: "website",
|
||||
}, ""
|
||||
}
|
|
@ -365,87 +365,90 @@ func ResetDB() {
|
|||
ctx := context.Background()
|
||||
|
||||
// Create the HMN database user
|
||||
credentials := append(
|
||||
[]pgCredentials{
|
||||
{getSystemUsername(), "", true}, // Postgres.app on Mac
|
||||
},
|
||||
guessCredentials()...,
|
||||
)
|
||||
|
||||
var superuserConn *pgconn.PgConn
|
||||
var connErrors []error
|
||||
for _, cred := range credentials {
|
||||
// NOTE(asaf): We have to use the low-level API of pgconn, because the pgx Exec always wraps the query in a transaction.
|
||||
var err error
|
||||
superuserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
|
||||
if err == nil {
|
||||
if cred.SafeToPrint {
|
||||
fmt.Printf("Connected by guessing username \"%s\" and password \"%s\".\n", cred.User, cred.Password)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
connErrors = append(connErrors, err)
|
||||
}
|
||||
}
|
||||
if superuserConn == nil {
|
||||
fmt.Println("Failed to connect to the db to reset it.")
|
||||
fmt.Println("The following errors occurred for each attempted set of credentials:")
|
||||
for _, err := range connErrors {
|
||||
fmt.Printf("- %v\n", err)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("If this is a local development environment, please let us know what platform you")
|
||||
fmt.Println("are using and how you installed Postgres. We want to try and streamline the setup")
|
||||
fmt.Println("process for you.")
|
||||
fmt.Println()
|
||||
fmt.Println("If on the other hand this is a real deployment, please go into psql and manually")
|
||||
fmt.Println("create the user:")
|
||||
fmt.Println()
|
||||
fmt.Println(" CREATE USER <username> WITH")
|
||||
fmt.Println(" ENCRYPTED PASSWORD '<password>'")
|
||||
fmt.Println(" CREATEDB;")
|
||||
fmt.Println()
|
||||
fmt.Println("and add the username and password to your config.")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer superuserConn.Close(ctx)
|
||||
|
||||
// Create the HMN user
|
||||
{
|
||||
result := superuserConn.ExecParams(ctx, fmt.Sprintf(`
|
||||
CREATE USER %s WITH
|
||||
ENCRYPTED PASSWORD '%s'
|
||||
CREATEDB
|
||||
`, config.Config.Postgres.User, config.Config.Postgres.Password), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
pgErr, isPgError := err.(*pgconn.PgError)
|
||||
if err != nil {
|
||||
if !(isPgError && pgErr.SQLState() == "42710") { // NOTE(ben): 42710 means "duplicate object", i.e. already exists
|
||||
panic(fmt.Errorf("failed to create HMN user: %w", err))
|
||||
credentials := append(
|
||||
[]pgCredentials{
|
||||
{config.Config.Postgres.User, config.Config.Postgres.Password, false}, // Existing HMN user
|
||||
{getSystemUsername(), "", true}, // Postgres.app on Mac
|
||||
},
|
||||
guessCredentials()...,
|
||||
)
|
||||
|
||||
var workingCred pgCredentials
|
||||
var createUserConn *pgconn.PgConn
|
||||
var connErrors []error
|
||||
for _, cred := range credentials {
|
||||
// NOTE(asaf): We have to use the low-level API of pgconn, because the pgx Exec always wraps the query in a transaction.
|
||||
var err error
|
||||
createUserConn, err = connectLowLevel(ctx, cred.User, cred.Password)
|
||||
if err == nil {
|
||||
workingCred = cred
|
||||
if cred.SafeToPrint {
|
||||
fmt.Printf("Connected by guessing username \"%s\" and password \"%s\".\n", cred.User, cred.Password)
|
||||
}
|
||||
break
|
||||
} else {
|
||||
connErrors = append(connErrors, err)
|
||||
}
|
||||
}
|
||||
if createUserConn == nil {
|
||||
fmt.Println("Failed to connect to the db to reset it.")
|
||||
fmt.Println("The following errors occurred for each attempted set of credentials:")
|
||||
for _, err := range connErrors {
|
||||
fmt.Printf("- %v\n", err)
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Println("If this is a local development environment, please let us know what platform you")
|
||||
fmt.Println("are using and how you installed Postgres. We want to try and streamline the setup")
|
||||
fmt.Println("process for you.")
|
||||
fmt.Println()
|
||||
fmt.Println("If on the other hand this is a real deployment, please go into psql and manually")
|
||||
fmt.Println("create the user:")
|
||||
fmt.Println()
|
||||
fmt.Println(" CREATE USER <username> WITH")
|
||||
fmt.Println(" ENCRYPTED PASSWORD '<password>'")
|
||||
fmt.Println(" CREATEDB;")
|
||||
fmt.Println()
|
||||
fmt.Println("and add the username and password to your config.")
|
||||
os.Exit(1)
|
||||
}
|
||||
defer createUserConn.Close(ctx)
|
||||
|
||||
// Create the HMN user
|
||||
{
|
||||
userExists := workingCred.User == config.Config.Postgres.User && workingCred.Password == config.Config.Postgres.Password
|
||||
if !userExists {
|
||||
result := createUserConn.ExecParams(ctx, fmt.Sprintf(`
|
||||
CREATE USER %s WITH
|
||||
ENCRYPTED PASSWORD '%s'
|
||||
CREATEDB
|
||||
`, config.Config.Postgres.User, config.Config.Postgres.Password), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to create HMN user: %w", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect all other users
|
||||
{
|
||||
result := superuserConn.ExecParams(ctx, fmt.Sprintf(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname IN ('%s', 'template1') AND pid <> pg_backend_pid()
|
||||
`, config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to disconnect other users: %w", err))
|
||||
}
|
||||
}
|
||||
superuserConn.Close(ctx)
|
||||
|
||||
// Connect as the HMN user
|
||||
conn, err := connectLowLevel(ctx, config.Config.Postgres.User, config.Config.Postgres.Password)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to connect to db: %w", err))
|
||||
}
|
||||
defer conn.Close(ctx)
|
||||
|
||||
// Disconnect all other users
|
||||
{
|
||||
result := conn.ExecParams(ctx, fmt.Sprintf(`
|
||||
SELECT pg_terminate_backend(pid)
|
||||
FROM pg_stat_activity
|
||||
WHERE datname IN ('%s', 'template1') AND pid <> pg_backend_pid()
|
||||
`, config.Config.Postgres.DbName), nil, nil, nil, nil)
|
||||
_, err := result.Close()
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("failed to disconnect other users: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Drop the database
|
||||
{
|
||||
|
|
|
@ -1,47 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddBackfillToDiscordMessage{})
|
||||
}
|
||||
|
||||
type AddBackfillToDiscordMessage struct{}
|
||||
|
||||
func (m AddBackfillToDiscordMessage) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 3, 28, 18, 41, 7, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddBackfillToDiscordMessage) Name() string {
|
||||
return "AddBackfillToDiscordMessage"
|
||||
}
|
||||
|
||||
func (m AddBackfillToDiscordMessage) Description() string {
|
||||
return "Add a backfill flag to discord messages"
|
||||
}
|
||||
|
||||
func (m AddBackfillToDiscordMessage) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE discord_message
|
||||
ADD COLUMN backfilled BOOLEAN NOT NULL default FALSE;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m AddBackfillToDiscordMessage) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE discord_message
|
||||
DROP COLUMN backfilled;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(newsletter{})
|
||||
}
|
||||
|
||||
type newsletter struct{}
|
||||
|
||||
func (m newsletter) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 5, 7, 1, 34, 32, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m newsletter) Name() string {
|
||||
return "newsletter"
|
||||
}
|
||||
|
||||
func (m newsletter) Description() string {
|
||||
return "Adds the newsletter signup"
|
||||
}
|
||||
|
||||
func (m newsletter) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
CREATE TABLE newsletter_emails (
|
||||
email VARCHAR(255) NOT NULL PRIMARY KEY
|
||||
);
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m newsletter) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
DROP TABLE newsletter_emails;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddHeaderImage{})
|
||||
}
|
||||
|
||||
type AddHeaderImage struct{}
|
||||
|
||||
func (m AddHeaderImage) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 6, 1, 2, 1, 18, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddHeaderImage) Name() string {
|
||||
return "AddHeaderImage"
|
||||
}
|
||||
|
||||
func (m AddHeaderImage) Description() string {
|
||||
return "Adds a header image to projects"
|
||||
}
|
||||
|
||||
func (m AddHeaderImage) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE project
|
||||
ADD COLUMN header_asset_id UUID REFERENCES asset (id) ON DELETE SET NULL;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m AddHeaderImage) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE project
|
||||
DROP COLUMN header_asset_id;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,47 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(RemoveDarkTheme{})
|
||||
}
|
||||
|
||||
type RemoveDarkTheme struct{}
|
||||
|
||||
func (m RemoveDarkTheme) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 6, 18, 2, 25, 36, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m RemoveDarkTheme) Name() string {
|
||||
return "RemoveDarkTheme"
|
||||
}
|
||||
|
||||
func (m RemoveDarkTheme) Description() string {
|
||||
return "Remove the darktheme field from users"
|
||||
}
|
||||
|
||||
func (m RemoveDarkTheme) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE hmn_user
|
||||
DROP COLUMN darktheme;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m RemoveDarkTheme) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE hmn_user
|
||||
ADD COLUMN darktheme BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,57 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddFollower{})
|
||||
}
|
||||
|
||||
type AddFollower struct{}
|
||||
|
||||
func (m AddFollower) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 6, 20, 19, 41, 34, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddFollower) Name() string {
|
||||
return "AddFollower"
|
||||
}
|
||||
|
||||
func (m AddFollower) Description() string {
|
||||
return "Add follower table"
|
||||
}
|
||||
|
||||
func (m AddFollower) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
CREATE TABLE follower (
|
||||
user_id int NOT NULL,
|
||||
following_user_id int REFERENCES hmn_user (id) ON DELETE CASCADE,
|
||||
following_project_id int REFERENCES project (id) ON DELETE CASCADE,
|
||||
CONSTRAINT user_id_or_project_id CHECK ((following_user_id IS NOT NULL AND following_project_id IS NULL) OR (following_user_id IS NULL AND following_project_id IS NOT NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX follower_user_id ON follower(user_id);
|
||||
CREATE UNIQUE INDEX follower_following_user ON follower (user_id, following_user_id);
|
||||
CREATE UNIQUE INDEX follower_following_project ON follower (user_id, following_project_id);
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m AddFollower) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
DROP INDEX follower_following_user;
|
||||
DROP INDEX follower_following_project;
|
||||
DROP INDEX follower_user_id;
|
||||
DROP TABLE follower;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmndata"
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddSnippetUpdatedField{})
|
||||
}
|
||||
|
||||
type AddSnippetUpdatedField struct{}
|
||||
|
||||
func (m AddSnippetUpdatedField) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 6, 29, 1, 43, 20, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddSnippetUpdatedField) Name() string {
|
||||
return "AddSnippetUpdatedField"
|
||||
}
|
||||
|
||||
func (m AddSnippetUpdatedField) Description() string {
|
||||
return "Add field to track most recent snippets on projects"
|
||||
}
|
||||
|
||||
func (m AddSnippetUpdatedField) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE project
|
||||
ADD COLUMN snippet_last_posted TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT 'epoch';
|
||||
`,
|
||||
))
|
||||
utils.Must(hmndata.UpdateSnippetLastPostedForAllProjects(ctx, tx))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m AddSnippetUpdatedField) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
_, err := tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE project
|
||||
DROP COLUMN snippet_last_posted;
|
||||
`,
|
||||
)
|
||||
return err
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddPrimaryToLinks{})
|
||||
}
|
||||
|
||||
type AddPrimaryToLinks struct{}
|
||||
|
||||
func (m AddPrimaryToLinks) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 6, 30, 23, 36, 30, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddPrimaryToLinks) Name() string {
|
||||
return "AddPrimaryToLinks"
|
||||
}
|
||||
|
||||
func (m AddPrimaryToLinks) Description() string {
|
||||
return "Adds 'primary_link' field to links"
|
||||
}
|
||||
|
||||
func (m AddPrimaryToLinks) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE link
|
||||
ADD COLUMN primary_link BOOLEAN NOT NULL DEFAULT false;
|
||||
`,
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m AddPrimaryToLinks) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE link
|
||||
DROP COLUMN primary_link;
|
||||
`,
|
||||
))
|
||||
return nil
|
||||
}
|
|
@ -1,50 +0,0 @@
|
|||
package migrations
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func init() {
|
||||
registerMigration(AddFeaturedToUser{})
|
||||
}
|
||||
|
||||
type AddFeaturedToUser struct{}
|
||||
|
||||
func (m AddFeaturedToUser) Version() types.MigrationVersion {
|
||||
return types.MigrationVersion(time.Date(2024, 7, 6, 15, 34, 32, 0, time.UTC))
|
||||
}
|
||||
|
||||
func (m AddFeaturedToUser) Name() string {
|
||||
return "AddFeaturedToUser"
|
||||
}
|
||||
|
||||
func (m AddFeaturedToUser) Description() string {
|
||||
return "Add featured flag to users"
|
||||
}
|
||||
|
||||
func (m AddFeaturedToUser) Up(ctx context.Context, tx pgx.Tx) error {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`
|
||||
ALTER TABLE hmn_user
|
||||
ADD COLUMN featured BOOLEAN NOT NULL DEFAULT false;
|
||||
CREATE INDEX hmn_user_featured ON hmn_user(featured);
|
||||
`,
|
||||
))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m AddFeaturedToUser) Down(ctx context.Context, tx pgx.Tx) error {
|
||||
utils.Must1(tx.Exec(ctx,
|
||||
`
|
||||
DROP INDEX hmn_user_featured;
|
||||
ALTER TABLE hmn_user
|
||||
DROP COLUMN featured;
|
||||
`,
|
||||
))
|
||||
return nil
|
||||
}
|
|
@ -289,7 +289,7 @@ func seedProject(ctx context.Context, tx pgx.Tx, input models.Project, owners []
|
|||
input.ForumEnabled, input.BlogEnabled,
|
||||
utils.OrDefault(input.DateCreated, time.Now()),
|
||||
)
|
||||
latestProjectId = utils.Max(latestProjectId, project.ID)
|
||||
latestProjectId = utils.IntMax(latestProjectId, project.ID)
|
||||
|
||||
// Create forum (even if unused)
|
||||
forum := db.MustQueryOne[models.Subforum](ctx, tx,
|
||||
|
|
|
@ -1,7 +0,0 @@
|
|||
package models
|
||||
|
||||
type Follow struct {
|
||||
UserID int `db:"user_id"`
|
||||
FollowingUserID *int `db:"following_user_id"`
|
||||
FollowingProjectID *int `db:"following_project_id"`
|
||||
}
|
|
@ -5,7 +5,6 @@ type Link struct {
|
|||
Name string `db:"name"`
|
||||
URL string `db:"url"`
|
||||
Ordering int `db:"ordering"`
|
||||
Primary bool `db:"primary_link"`
|
||||
UserID *int `db:"user_id"`
|
||||
ProjectID *int `db:"project_id"`
|
||||
}
|
||||
|
|
|
@ -1,5 +0,0 @@
|
|||
package models
|
||||
|
||||
type NewsletterEmail struct {
|
||||
Email string `db:"email"`
|
||||
}
|
|
@ -53,6 +53,8 @@ func (lc ProjectLifecycle) In(lcs []ProjectLifecycle) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
const RecentProjectUpdateTimespanSec = 60 * 60 * 24 * 28 // NOTE(asaf): Four weeks
|
||||
|
||||
type Project struct {
|
||||
ID int `db:"id"`
|
||||
|
||||
|
@ -79,7 +81,6 @@ type Project struct {
|
|||
ForumLastUpdated time.Time `db:"forum_last_updated"`
|
||||
BlogLastUpdated time.Time `db:"blog_last_updated"`
|
||||
AnnotationLastUpdated time.Time `db:"annotation_last_updated"`
|
||||
SnippetLastPosted time.Time `db:"snippet_last_posted"`
|
||||
|
||||
ForumEnabled bool `db:"forum_enabled"`
|
||||
BlogEnabled bool `db:"blog_enabled"`
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Subforum struct {
|
||||
|
@ -42,7 +43,7 @@ func (node *SubforumTreeNode) GetLineage() []*Subforum {
|
|||
return result
|
||||
}
|
||||
|
||||
func GetFullSubforumTree(ctx context.Context, conn db.ConnOrTx) SubforumTree {
|
||||
func GetFullSubforumTree(ctx context.Context, conn *pgxpool.Pool) SubforumTree {
|
||||
subforums, err := db.Query[Subforum](ctx, conn,
|
||||
`
|
||||
SELECT $columns
|
||||
|
|
|
@ -12,12 +12,6 @@ const (
|
|||
ThreadTypePersonalBlogPost
|
||||
)
|
||||
|
||||
var ValidThreadTypes = []ThreadType{
|
||||
ThreadTypeProjectBlogPost,
|
||||
ThreadTypeForumPost,
|
||||
ThreadTypePersonalBlogPost,
|
||||
}
|
||||
|
||||
type Thread struct {
|
||||
ID int `db:"id"`
|
||||
|
||||
|
|
|
@ -1,40 +0,0 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type TimelineItemType string
|
||||
|
||||
const (
|
||||
TimelineItemTypeSnippet TimelineItemType = "snippet"
|
||||
TimelineItemTypePost TimelineItemType = "post"
|
||||
TimelineItemTypeStream TimelineItemType = "stream" // NOTE(asaf): Not currently supported
|
||||
)
|
||||
|
||||
// NOTE(asaf): This is a virtual model made up of several different tables
|
||||
type TimelineItem struct {
|
||||
// Common
|
||||
// NOTE(asaf): Several different items can have the same ID because we're merging several tables
|
||||
ID int `db:"id"`
|
||||
Date time.Time `db:"\"when\""`
|
||||
Type TimelineItemType `db:"timeline_type"`
|
||||
OwnerID int `db:"owner_id"`
|
||||
Title string `db:"title"`
|
||||
ParsedDescription string `db:"parsed_desc"`
|
||||
RawDescription string `db:"raw_desc"`
|
||||
|
||||
// Snippet
|
||||
AssetID *uuid.UUID `db:"asset_id"`
|
||||
DiscordMessageID *string `db:"discord_message_id"`
|
||||
ExternalUrl *string `db:"url"`
|
||||
|
||||
// Post
|
||||
ProjectID int `db:"project_id"`
|
||||
ThreadID int `db:"thread_id"`
|
||||
SubforumID int `db:"subforum_id"`
|
||||
ThreadType ThreadType `db:"thread_type"`
|
||||
FirstPost bool `db:"first_post"`
|
||||
}
|
|
@ -31,7 +31,6 @@ type User struct {
|
|||
IsStaff bool `db:"is_staff"`
|
||||
Status UserStatus `db:"status"`
|
||||
EducationRole EduRole `db:"education_role"`
|
||||
Featured bool `db:"featured"`
|
||||
|
||||
Name string `db:"name"`
|
||||
Bio string `db:"bio"`
|
||||
|
@ -39,7 +38,8 @@ type User struct {
|
|||
Signature string `db:"signature"`
|
||||
AvatarAssetID *uuid.UUID `db:"avatar_asset_id"`
|
||||
|
||||
Timezone string `db:"timezone"`
|
||||
DarkTheme bool `db:"darktheme"`
|
||||
Timezone string `db:"timezone"`
|
||||
|
||||
ShowEmail bool `db:"showemail"`
|
||||
|
||||
|
|
|
@ -5,25 +5,16 @@ package main
|
|||
import (
|
||||
"syscall/js"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/links"
|
||||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
)
|
||||
|
||||
func main() {
|
||||
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
js.Global().Set("parseMarkdown", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
return parsing.ParseMarkdown(args[0].String(), parsing.ForumPreviewMarkdown)
|
||||
}))
|
||||
js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
js.Global().Set("parseMarkdownEdu", js.FuncOf(func(this js.Value, args []js.Value) interface{} {
|
||||
return parsing.ParseMarkdown(args[0].String(), parsing.EducationPreviewMarkdown)
|
||||
}))
|
||||
js.Global().Set("parseKnownServicesForUrl", js.FuncOf(func(this js.Value, args []js.Value) any {
|
||||
service, username := links.ParseKnownServicesForUrl(args[0].String())
|
||||
return js.ValueOf(map[string]any{
|
||||
"service": service.Name,
|
||||
"icon": service.IconName,
|
||||
"username": username,
|
||||
})
|
||||
}))
|
||||
|
||||
var done chan struct{}
|
||||
<-done // block forever
|
||||
|
|
|
@ -174,8 +174,7 @@ const PerfContextKey = "HMNPerf"
|
|||
func ExtractPerf(ctx context.Context) *RequestPerf {
|
||||
iperf := ctx.Value(PerfContextKey)
|
||||
if iperf == nil {
|
||||
// NOTE(asaf): Returning a dummy perf so we don't crash if it's missing
|
||||
return MakeNewRequestPerf("PERF MISSING", "PERF MISSING", "PERF MISSING")
|
||||
return nil
|
||||
}
|
||||
return iperf.(*RequestPerf)
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 2.1 KiB |
After Width: | Height: | Size: 2.1 KiB |
|
@ -1,175 +0,0 @@
|
|||
.post-content {
|
||||
line-height: 1.4;
|
||||
|
||||
* {
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5 {
|
||||
line-height: 1.2;
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
li:not(:last-child) {
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
li p {
|
||||
margin-top: 0.6em;
|
||||
margin-bottom: 0.6em;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin-left: var(--spacing-3);
|
||||
margin-right: var(--spacing-3);
|
||||
}
|
||||
|
||||
div.code {
|
||||
border-color: var(--dimmer-color);
|
||||
|
||||
max-width: 100%;
|
||||
max-height: 20em;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
-moz-tab-size: 4;
|
||||
tab-size: 4;
|
||||
border-width: 1px;
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
div+br,
|
||||
blockquote+br,
|
||||
ul+br,
|
||||
ol+br {
|
||||
display: none;
|
||||
}
|
||||
|
||||
table {
|
||||
margin-top: var(--spacing-2);
|
||||
margin-bottom: var(--spacing-2);
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--c1);
|
||||
|
||||
padding: .2em 0;
|
||||
white-space: nowrap;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "\00a0";
|
||||
letter-spacing: -0.2em;
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
pre>code,
|
||||
pre.hmn-code {
|
||||
background-color: var(--c1);
|
||||
|
||||
padding: 0.7em;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin-left: var(--spacing-2);
|
||||
padding-left: var(--spacing-2);
|
||||
margin-right: 0;
|
||||
border-left-style: solid;
|
||||
border-left-width: 1px;
|
||||
|
||||
.quotewho {
|
||||
line-height: 2em;
|
||||
|
||||
&::after {
|
||||
content: " said:";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: var(--spacing-2) 0;
|
||||
padding: var(--spacing-2) var(--spacing-2) 0;
|
||||
background-color: var(--dim-background);
|
||||
border-radius: var(--border-radius-3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
margin-top: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
padding: var(--spacing-3) var(--spacing-3) 0;
|
||||
}
|
||||
|
||||
figcaption {
|
||||
margin: var(--spacing-1) 0;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
margin: var(--spacing-2) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
td {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.spoiler {
|
||||
border-color: var(--c1);
|
||||
|
||||
border-width: 1px;
|
||||
border-style: dashed;
|
||||
color: transparent;
|
||||
|
||||
&::selection {
|
||||
color: white;
|
||||
background-color: black;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,675 +0,0 @@
|
|||
* {
|
||||
/* It's aggressive, but we like it aggressive */
|
||||
box-sizing: border-box;
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--c2);
|
||||
color: var(--color);
|
||||
font-family: "Inter", sans-serif;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
a,
|
||||
.link {
|
||||
color: var(--link-color);
|
||||
border-bottom: none;
|
||||
text-decoration: none;
|
||||
|
||||
&.external::after {
|
||||
font-family: "icons";
|
||||
content: " 1";
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
||||
.link-normal {
|
||||
--link-color: var(--color);
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
.mono {
|
||||
/* TODO(redesign): We're not using Fira any more. */
|
||||
font-family: "Fira Mono", monospace;
|
||||
}
|
||||
|
||||
br {
|
||||
/* why, IE... */
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 0;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Utility */
|
||||
|
||||
.m-center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.flex-shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.flex-grow-1 {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.flex-fair {
|
||||
flex-basis: 1px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
.flex-fair-ns {
|
||||
flex-basis: 1px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 60em) {
|
||||
.flex-fair-l {
|
||||
flex-basis: 1px;
|
||||
flex-grow: 1;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.c-normal {
|
||||
color: var(--color);
|
||||
}
|
||||
|
||||
.c-white {
|
||||
--color: #fff;
|
||||
}
|
||||
|
||||
.c-inherit {
|
||||
color: inherit;
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.bg1 {
|
||||
background-color: var(--c1);
|
||||
}
|
||||
|
||||
.bg2 {
|
||||
background-color: var(--c2);
|
||||
}
|
||||
|
||||
.bg3 {
|
||||
background-color: var(--c3);
|
||||
}
|
||||
|
||||
.bg4 {
|
||||
background-color: var(--c4);
|
||||
}
|
||||
|
||||
.bg5 {
|
||||
background-color: var(--c5);
|
||||
}
|
||||
|
||||
.bg-transparent {
|
||||
background-color: var(--c-transparent-background);
|
||||
}
|
||||
|
||||
.f8 {
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.w6 {
|
||||
width: var(--width-6);
|
||||
}
|
||||
|
||||
.w7 {
|
||||
width: var(--width-7);
|
||||
}
|
||||
|
||||
.w8 {
|
||||
width: var(--width-8);
|
||||
}
|
||||
|
||||
.mw-site {
|
||||
max-width: var(--site-width);
|
||||
}
|
||||
|
||||
.mw-site-narrow {
|
||||
max-width: var(--site-width-narrow);
|
||||
}
|
||||
|
||||
.maxh-3 {
|
||||
max-height: var(--height-3);
|
||||
}
|
||||
|
||||
.maxh-4 {
|
||||
max-height: var(--height-4);
|
||||
}
|
||||
|
||||
.maxh-5 {
|
||||
max-height: var(--height-5);
|
||||
}
|
||||
|
||||
.maxh-6 {
|
||||
max-height: var(--height-6);
|
||||
}
|
||||
|
||||
.maxh-100 {
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
.maxh-50vh {
|
||||
max-height: 50vh;
|
||||
}
|
||||
|
||||
.maxh-60vh {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.maxh-70vh {
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.maxh-80vh {
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.minw-100 {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.minh-1 {
|
||||
min-height: var(--height-1);
|
||||
}
|
||||
|
||||
.minh-2 {
|
||||
min-height: var(--height-2);
|
||||
}
|
||||
|
||||
.minh-3 {
|
||||
min-height: var(--height-3);
|
||||
}
|
||||
|
||||
.minh-4 {
|
||||
min-height: var(--height-4);
|
||||
}
|
||||
|
||||
.minh-5 {
|
||||
min-height: var(--height-5);
|
||||
}
|
||||
|
||||
.minh-6 {
|
||||
min-height: var(--height-6);
|
||||
}
|
||||
|
||||
.g0 {
|
||||
gap: var(--spacing-0);
|
||||
}
|
||||
|
||||
.g1 {
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.g2 {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.g3 {
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.g4 {
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.g5 {
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.grid-1 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.aspect-ratio--2x1 {
|
||||
padding-bottom: 50%;
|
||||
}
|
||||
|
||||
.hide-if-empty:empty {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
:not([hidden])+.show-when-sibling-hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fill-current {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.rot-180 {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.grab:hover {
|
||||
cursor: grab;
|
||||
|
||||
.grabbing & {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.grabbing {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/*
|
||||
TODO(redesign): It's really unfortunate that we rely on text stuff so much...it
|
||||
makes all our SVGs fuzzy. Evaluate the places we use this and see if we can use the
|
||||
lite variant instead. (This will require us to carefully set width and height attributes
|
||||
on our SVGs to ensure that they naturally render at the right size.)
|
||||
*/
|
||||
.svgicon {
|
||||
svg {
|
||||
fill: currentColor;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&:not(.svgicon-nofix) svg {
|
||||
transform: translate(0px, 0.1em);
|
||||
}
|
||||
}
|
||||
|
||||
.svgicon-lite {
|
||||
svg {
|
||||
fill: currentColor;
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.sr {
|
||||
border: 0;
|
||||
clip: rect(1px, 1px, 1px, 1px);
|
||||
clip-path: inset(50%);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
word-wrap: normal !important;
|
||||
transition: 0.2s all;
|
||||
}
|
||||
|
||||
.sr-focusable:focus {
|
||||
padding: 15px 10px;
|
||||
height: auto;
|
||||
width: auto;
|
||||
background: var(--content-background);
|
||||
clip: initial;
|
||||
clip-path: initial;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.breadcrumb {
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&.current {
|
||||
text-overflow: clip ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
/* NOTE(asaf): Tachyons uses a padding trick instead of using the actual property */
|
||||
.aspect-ratio-real--1x1 {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
|
||||
.center-abs {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
.bg1-ns {
|
||||
background-color: var(--c1);
|
||||
}
|
||||
|
||||
.bg2-ns {
|
||||
background-color: var(--c2);
|
||||
}
|
||||
|
||||
.bg3-ns {
|
||||
background-color: var(--c3);
|
||||
}
|
||||
|
||||
.bg4-ns {
|
||||
background-color: var(--c4);
|
||||
}
|
||||
|
||||
.bg5-ns {
|
||||
background-color: var(--c5);
|
||||
}
|
||||
|
||||
.g0-ns {
|
||||
gap: var(--spacing-0);
|
||||
}
|
||||
|
||||
.g1-ns {
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.g2-ns {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.g3-ns {
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.g4-ns {
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.g5-ns {
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.w6-ns {
|
||||
width: var(--width-6);
|
||||
}
|
||||
|
||||
.w7-ns {
|
||||
width: var(--width-7);
|
||||
}
|
||||
|
||||
.w8-ns {
|
||||
width: var(--width-8);
|
||||
}
|
||||
|
||||
.grid-1-ns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-2-ns {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 35em) and (max-width: 60em) {
|
||||
.bg1-m {
|
||||
background-color: var(--c1);
|
||||
}
|
||||
|
||||
.bg2-m {
|
||||
background-color: var(--c2);
|
||||
}
|
||||
|
||||
.bg3-m {
|
||||
background-color: var(--c3);
|
||||
}
|
||||
|
||||
.bg4-m {
|
||||
background-color: var(--c4);
|
||||
}
|
||||
|
||||
.bg5-m {
|
||||
background-color: var(--c5);
|
||||
}
|
||||
|
||||
.g0-m {
|
||||
gap: var(--spacing-0);
|
||||
}
|
||||
|
||||
.g1-m {
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.g2-m {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.g3-m {
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.g4-m {
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.g5-m {
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.w6-m {
|
||||
width: var(--width-6);
|
||||
}
|
||||
|
||||
.w7-m {
|
||||
width: var(--width-7);
|
||||
}
|
||||
|
||||
.w8-m {
|
||||
width: var(--width-8);
|
||||
}
|
||||
|
||||
.grid-1-m {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-2-m {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 60em) {
|
||||
.bg1-l {
|
||||
background-color: var(--c1);
|
||||
}
|
||||
|
||||
.bg2-l {
|
||||
background-color: var(--c2);
|
||||
}
|
||||
|
||||
.bg3-l {
|
||||
background-color: var(--c3);
|
||||
}
|
||||
|
||||
.bg4-l {
|
||||
background-color: var(--c4);
|
||||
}
|
||||
|
||||
.bg5-l {
|
||||
background-color: var(--c5);
|
||||
}
|
||||
|
||||
.g0-l {
|
||||
gap: var(--spacing-0);
|
||||
}
|
||||
|
||||
.g1-l {
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.g2-l {
|
||||
gap: var(--spacing-2);
|
||||
}
|
||||
|
||||
.g3-l {
|
||||
gap: var(--spacing-3);
|
||||
}
|
||||
|
||||
.g4-l {
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.g5-l {
|
||||
gap: var(--spacing-5);
|
||||
}
|
||||
|
||||
.w6-l {
|
||||
width: var(--width-6);
|
||||
}
|
||||
|
||||
.w7-l {
|
||||
width: var(--width-7);
|
||||
}
|
||||
|
||||
.w8-l {
|
||||
width: var(--width-8);
|
||||
}
|
||||
|
||||
.grid-1-l {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.grid-2-l {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO(redesign): Compatibility styles; do not use in new designs. Remove when redesign is complete. */
|
||||
|
||||
.b--theme-dark {
|
||||
border-color: var(--theme-color-dark);
|
||||
}
|
||||
|
||||
.b--theme-light {
|
||||
border-color: var(--theme-color-light);
|
||||
}
|
||||
|
||||
.bg-theme {
|
||||
background-color: var(--theme-color);
|
||||
}
|
||||
|
||||
.bg-theme-dim {
|
||||
background-color: var(--theme-color-dim);
|
||||
}
|
||||
|
||||
.bg-theme-dimmer {
|
||||
background-color: var(--theme-color-dimmer);
|
||||
}
|
||||
|
||||
.bg-theme-dimmest {
|
||||
background-color: var(--theme-color-dimmest);
|
||||
}
|
||||
|
||||
.bg-theme-dark {
|
||||
background-color: var(--theme-color-dark);
|
||||
}
|
||||
|
||||
.bg-theme-light {
|
||||
background-color: var(--theme-color-light);
|
||||
}
|
||||
|
||||
.background-even:nth-of-type(even) {
|
||||
background-color: var(--background-even-background);
|
||||
--fade-color: var(--background-even-background);
|
||||
}
|
||||
|
||||
.optionbar {
|
||||
width: 100%;
|
||||
padding-bottom: var(--spacing-2);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
border-style: dashed;
|
||||
border-width: 0 0 1px;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
flex-direction: row;
|
||||
text-align: left;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
&.bottom {
|
||||
border-bottom-width: 0;
|
||||
border-top-width: 1px;
|
||||
padding-bottom: 0;
|
||||
padding-top: var(--spacing-2);
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.center {
|
||||
/* TODO: find this and kill it */
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: var(--spacing-1) var(--spacing-2);
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
padding-top: var(--spacing-2);
|
||||
padding-bottom: var(--spacing-2);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: inline-block;
|
||||
height: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
|
@ -1,75 +0,0 @@
|
|||
#preview:empty::after {
|
||||
content: 'A preview of your post will appear here.';
|
||||
color: var(--dimmer-color);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
#preview-container {
|
||||
max-height: calc(100vh - 20rem);
|
||||
overflow: auto;
|
||||
}
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
.edit-form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-top: var(--spacing-3);
|
||||
margin-bottom: var(--spacing-3);
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
> :first-child {
|
||||
width: 100%;
|
||||
font-weight: 500;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
text-align: left;
|
||||
padding-right: 0;
|
||||
padding-bottom: var(--spacing-1);
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
width: var(--width-4);
|
||||
text-align: right;
|
||||
padding-right: var(--spacing-2);
|
||||
padding-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
> :nth-child(2) {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.pt-input-ns {
|
||||
@media screen and (min-width: 35em) {
|
||||
padding-top: var(--spacing-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type=text] {
|
||||
width: 100%;
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
max-width: var(--width-5);
|
||||
}
|
||||
}
|
||||
|
||||
input[type=text]:invalid {
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: var(--height-3);
|
||||
|
||||
@media screen and (min-width: 35em) {
|
||||
width: var(--width-6);
|
||||
}
|
||||
}
|
||||
}
|