feat: Update website, API, and infra (#164)

>Adds `maitred` in charge of handling automated game installs, updates,
and even execution.

>Not only that, we have the hosted stuff here
>- [x] AWS Task on ECS GPUs
>- [ ] Add a service to listen for game starts and stops
(docker-compose.yml)
>- [x] Add a queue for requesting a game to start
>- [x] Fix up the play/watch UI 

>TODO:
>- Add a README
>- Add an SST docs

Edit:

- This adds a new landing page, updates the homepage etc etc
>I forgot what the rest of the updated stuff are 😅
This commit is contained in:
Wanjohi
2025-02-11 12:26:35 +03:00
committed by GitHub
parent 93327bdf1a
commit 060718d8b0
139 changed files with 5814 additions and 5049 deletions

View File

@@ -1,50 +0,0 @@
package cmd
import (
"runtime/debug"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "nestri",
Short: "A CLI tool to run and manage your self-hosted cloud gaming service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return cmd.Help()
},
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func Execute() error {
err := rootCmd.Execute()
return err
}
var (
// Version stores the build version of VHS at the time of package through
// -ldflags.
//
// go build -ldflags "-s -w -X=main.Version=$(VERSION)"
Version string
// CommitSHA stores the git commit SHA at the time of package through -ldflags.
CommitSHA string
)
func init() {
rootCmd.AddCommand(runCmd)
if len(CommitSHA) >= 7 { //nolint:gomnd
vt := rootCmd.VersionTemplate()
rootCmd.SetVersionTemplate(vt[:len(vt)-1] + " (" + CommitSHA[0:7] + ")\n")
}
if Version == "" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Sum != "" {
Version = info.Main.Version
} else {
Version = "unknown (built from source)"
}
}
rootCmd.Version = Version
}

View File

@@ -1,26 +0,0 @@
package cmd
import (
"nestrilabs/cli/internal/auth"
"github.com/charmbracelet/log"
"github.com/spf13/cobra"
)
var runCmd = &cobra.Command{
Use: "run",
Short: "Run a new Nestri node",
Long: "Create and run a new Nestri node from this machine",
Args: cobra.NoArgs,
RunE: func(_ *cobra.Command, _ []string) error {
credentials, err := auth.FetchUserCredentials()
if err != nil {
return err
}
log.Info("Credentials", "access_token", credentials.AccessToken)
log.Info("Credentials", "refresh_token", credentials.RefreshToken)
return nil
},
}

View File

@@ -1,54 +0,0 @@
module nestrilabs/cli
go 1.23.3
require (
github.com/charmbracelet/log v0.4.0
github.com/docker/docker v27.4.1+incompatible
github.com/gorilla/websocket v1.5.3
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3
github.com/spf13/cobra v1.8.1
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.13.0 // indirect
github.com/charmbracelet/x/ansi v0.2.3 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/sjson v1.2.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
go.opentelemetry.io/otel v1.33.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 // indirect
go.opentelemetry.io/otel/metric v1.33.0 // indirect
go.opentelemetry.io/otel/sdk v1.33.0 // indirect
go.opentelemetry.io/otel/trace v1.33.0 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/time v0.8.0 // indirect
gotest.tools/v3 v3.5.1 // indirect
)

View File

@@ -1,169 +0,0 @@
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3 h1:IqtLHbOF3y/SD3riYYKauQKj9dpqU7uuEExqL5zQ390=
github.com/nestrilabs/nestri-go-sdk v0.1.0-alpha.3/go.mod h1:b4AuAQSxfqtAzu4ie0Q+NOVNF9YUZTyP4XnxK0ZN05U=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0=
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.32.0 h1:ZqPmj8Kzc+Y6e0+skZsuACbx+wzMgo5MQsJh9Qd6aYI=
golang.org/x/net v0.32.0/go.mod h1:CwU0IoeOlnQQWJ6ioyFrfRuomB8GKF6KbYXZVyeXNfs=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/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/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576 h1:CkkIfIt50+lT6NHAVoRYEyAvQGFM7xEwXUUywFvEb3Q=
google.golang.org/genproto/googleapis/api v0.0.0-20241209162323-e6fa225c2576/go.mod h1:1R3kvZ1dtP3+4p4d3G8uJ8rFk/fWlScl38vanWACI08=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576 h1:8ZmaLZE4XWrtU3MyClkYqqtl6Oegr3235h7jxsDyqCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20241209162323-e6fa225c2576/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0=
google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=

View File

@@ -1,26 +0,0 @@
package api
import (
"context"
"fmt"
"nestrilabs/cli/internal/resource"
"github.com/nestrilabs/nestri-go-sdk"
"github.com/nestrilabs/nestri-go-sdk/option"
)
func RegisterMachine(token string) {
client := nestri.NewClient(
option.WithBearerToken(token),
option.WithBaseURL(resource.Resource.Api.Url),
)
machine, err := client.Machines.New(
context.TODO(),
nestri.MachineNewParams{})
if err != nil {
panic(err.Error())
}
fmt.Printf("%+v\n", machine.Data)
}

View File

@@ -1,202 +0,0 @@
package machine
import (
"fmt"
"os"
"os/exec"
"regexp"
"strings"
"github.com/charmbracelet/log"
)
type Machine struct {
OperatingSystem string
Arch string
Kernel string
Virtualization string
Hostname string
}
func NewMachine() *Machine {
var OS string
var architecture string
var kernel string
var virtualisation string
var hostname string
output, _ := exec.Command("hostnamectl", "status").Output()
os := regexp.MustCompile(`Operating System:\s+(.*)`)
matchingOS := os.FindStringSubmatch(string(output))
if len(matchingOS) > 1 {
OS = matchingOS[1]
}
arch := regexp.MustCompile(`Architecture:\s+(\w+)`)
matchingArch := arch.FindStringSubmatch(string(output))
if len(matchingArch) > 1 {
architecture = matchingArch[1]
}
kern := regexp.MustCompile(`Kernel:\s+(.*)`)
matchingKernel := kern.FindStringSubmatch(string(output))
if len(matchingKernel) > 1 {
kernel = matchingKernel[1]
}
virt := regexp.MustCompile(`Virtualization:\s+(\w+)`)
matchingVirt := virt.FindStringSubmatch(string(output))
if len(matchingVirt) > 1 {
virtualisation = matchingVirt[1]
}
host := regexp.MustCompile(`Static hostname:\s+(.*)`)
matchingHost := host.FindStringSubmatch(string(output))
if len(matchingHost) > 1 {
hostname = matchingHost[1]
}
return &Machine{
OperatingSystem: OS,
Arch: architecture,
Kernel: kernel,
Virtualization: virtualisation,
Hostname: hostname,
}
}
func (m *Machine) GetOS() string {
if m.OperatingSystem != "" {
return m.OperatingSystem
}
return "unknown"
}
func (m *Machine) GetArchitecture() string {
if m.Arch != "" {
return m.Arch
}
return "unknown"
}
func (m *Machine) GetKernel() string {
if m.Kernel != "" {
return m.Kernel
}
return "unknown"
}
func (m *Machine) GetVirtualization() string {
if m.Virtualization != "" {
return m.Virtualization
}
return "none"
}
func (m *Machine) GetHostname() string {
if m.Hostname != "" {
return m.Hostname
}
return "unknown"
}
func (m *Machine) GetMachineID() string {
id, err := os.ReadFile("/etc/machine-id")
if err != nil {
log.Error("Error getting your machine's ID", "err", err)
os.Exit(1)
}
return strings.TrimSpace(string(id))
}
func (m *Machine) GPUInfo() (string, string, error) {
// The command for GPU information varies depending on the system and drivers.
// lshw is a good general-purpose tool, but might need adjustments for specific hardware.
output, err := exec.Command("lshw", "-C", "display").Output()
if err != nil {
return "", "", fmt.Errorf("failed to get GPU information: %w", err)
}
gpuType := ""
gpuSize := ""
// Regular expressions for extracting product and size information. These might need to be
// adapted based on the output of lshw on your specific system.
typeRegex := regexp.MustCompile(`product:\s+(.*)`)
sizeRegex := regexp.MustCompile(`size:\s+(\d+MiB)`) // Example: extracts size in MiB
typeMatch := typeRegex.FindStringSubmatch(string(output))
if len(typeMatch) > 1 {
gpuType = typeMatch[1]
}
sizeMatch := sizeRegex.FindStringSubmatch(string(output))
if len(sizeMatch) > 1 {
gpuSize = sizeMatch[1]
}
if gpuType == "" && gpuSize == "" {
return "", "", fmt.Errorf("could not parse GPU information using lshw")
}
return gpuType, gpuSize, nil
}
func (m *Machine) GetCPUInfo() (string, string, error) {
output, err := exec.Command("lscpu").Output()
if err != nil {
return "", "", fmt.Errorf("failed to get CPU information: %w", err)
}
cpuType := ""
cpuSize := "" // This will store the number of cores
typeRegex := regexp.MustCompile(`Model name:\s+(.*)`)
coresRegex := regexp.MustCompile(`CPU\(s\):\s+(\d+)`)
typeMatch := typeRegex.FindStringSubmatch(string(output))
if len(typeMatch) > 1 {
cpuType = typeMatch[1]
}
coresMatch := coresRegex.FindStringSubmatch(string(output))
if len(coresMatch) > 1 {
cpuSize = coresMatch[1]
}
if cpuType == "" && cpuSize == "" {
return "", "", fmt.Errorf("could not parse CPU information using lscpu")
}
return cpuType, cpuSize, nil
}
func (m *Machine) GetRAMSize() (string, error) {
output, err := exec.Command("free", "-h", "--si").Output() // Using -h for human-readable and --si for base-10 units
if err != nil {
return "", fmt.Errorf("failed to get RAM information: %w", err)
}
ramSize := ""
ramRegex := regexp.MustCompile(`Mem:\s+(\S+)`) // Matches the total memory size
ramMatch := ramRegex.FindStringSubmatch(string(output))
if len(ramMatch) > 1 {
ramSize = ramMatch[1]
} else {
return "", fmt.Errorf("could not parse RAM information from free command")
}
return ramSize, nil
}
// func cleanString(s string) string {
// s = strings.ToLower(s)
// reg := regexp.MustCompile("[^a-z0-9]+") // Matches one or more non-alphanumeric characters
// return reg.ReplaceAllString(s, "")
// }

View File

@@ -1,118 +0,0 @@
package party
import (
"fmt"
"nestrilabs/cli/internal/machine"
"nestrilabs/cli/internal/resource"
"net/http"
"net/url"
"time"
"github.com/charmbracelet/log"
"github.com/gorilla/websocket"
)
const (
// Initial retry delay
initialRetryDelay = 1 * time.Second
// Maximum retry delay
maxRetryDelay = 30 * time.Second
// Factor to increase delay by after each attempt
backoffFactor = 2
)
type Party struct {
// Channel to signal shutdown
done chan struct{}
fingerprint string
hostname string
}
func NewParty() *Party {
m := machine.NewMachine()
fingerpint := m.GetMachineID()
return &Party{
done: make(chan struct{}),
fingerprint: fingerpint,
hostname: m.Hostname,
}
}
// Shutdown gracefully closes the connection
func (p *Party) Shutdown() {
close(p.done)
}
func (p *Party) Connect() {
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", p.fingerprint)
params := url.Values{}
params.Add("_pk", p.hostname)
wsURL := baseURL + "?" + params.Encode()
retryDelay := initialRetryDelay
header := http.Header{}
bearer := fmt.Sprintf("Bearer %s", resource.Resource.AuthFingerprintKey.Value)
header.Add("Authorization", bearer)
for {
select {
case <-p.done:
log.Info("Shutting down connection")
return
default:
conn, _, err := websocket.DefaultDialer.Dial(wsURL, header)
if err != nil {
log.Error("Failed to connect to party server", "err", err)
time.Sleep(retryDelay)
// Increase retry delay exponentially, but cap it
retryDelay = time.Duration(float64(retryDelay) * backoffFactor)
if retryDelay > maxRetryDelay {
retryDelay = maxRetryDelay
}
continue
}
log.Info("Connection to server", "url", wsURL)
// Reset retry delay on successful connection
retryDelay = initialRetryDelay
// Handle connection in a separate goroutine
connectionClosed := make(chan struct{})
go func() {
defer close(connectionClosed)
defer conn.Close()
// Send initial message
// if err := conn.WriteMessage(websocket.TextMessage, []byte("hello there")); err != nil {
// log.Error("Failed to send initial message", "err", err)
// return
// }
// Read messages loop
for {
select {
case <-p.done:
return
default:
_, message, err := conn.ReadMessage()
if err != nil {
log.Error("Error reading message", "err", err)
return
}
log.Info("Received message from party server", "message", string(message))
}
}
}()
// Wait for either connection to close or shutdown signal
select {
case <-connectionClosed:
log.Warn("Connection closed, attempting to reconnect...")
time.Sleep(retryDelay)
case <-p.done:
log.Info("Shutting down connection")
return
}
}
}
}

View File

@@ -1,125 +0,0 @@
package party
import (
"encoding/json"
"fmt"
"nestrilabs/cli/internal/machine"
"net/url"
"time"
"github.com/charmbracelet/log"
"github.com/gorilla/websocket"
)
// RetryConfig holds configuration for retry behavior
type RetryConfig struct {
InitialDelay time.Duration
MaxDelay time.Duration
BackoffFactor float64
MaxAttempts int // use 0 for infinite retries
}
// DefaultRetryConfig provides sensible default values
var DefaultRetryConfig = RetryConfig{
InitialDelay: time.Second,
MaxDelay: 30 * time.Second,
BackoffFactor: 2.0,
MaxAttempts: 0, // infinite retries
}
// RetryFunc is a function that will be retried
type RetryFunc[T any] func() (T, error)
// Retry executes the given function with retries based on the config
func Retry[T any](config RetryConfig, operation RetryFunc[T]) (T, error) {
var result T
currentDelay := config.InitialDelay
attempts := 0
for {
if config.MaxAttempts > 0 && attempts >= config.MaxAttempts {
return result, fmt.Errorf("max retry attempts (%d) exceeded", config.MaxAttempts)
}
result, err := operation()
if err == nil {
return result, nil
}
log.Warn("Operation failed, retrying...",
"attempt", attempts+1,
"delay", currentDelay,
"error", err)
time.Sleep(currentDelay)
// Increase delay for next attempt
currentDelay = time.Duration(float64(currentDelay) * config.BackoffFactor)
if currentDelay > config.MaxDelay {
currentDelay = config.MaxDelay
}
attempts++
}
}
// MessageHandler processes a message and returns true if it's the expected type
type MessageHandler[T any] func(msg T) bool
type TypeListener[T any] struct {
retryConfig RetryConfig
handler MessageHandler[T]
fingerprint string
hostname string
}
func NewTypeListener[T any](handler MessageHandler[T]) *TypeListener[T] {
m := machine.NewMachine()
fingerprint := m.GetMachineID()
return &TypeListener[T]{
retryConfig: DefaultRetryConfig,
handler: handler,
fingerprint: fingerprint,
hostname: m.Hostname,
}
}
// SetRetryConfig allows customizing the retry behavior
func (t *TypeListener[T]) SetRetryConfig(config RetryConfig) {
t.retryConfig = config
}
func (t *TypeListener[T]) ConnectUntilMessage() (T, error) {
baseURL := fmt.Sprintf("ws://localhost:1999/parties/main/%s", t.fingerprint)
params := url.Values{}
params.Add("_pk", t.hostname)
wsURL := baseURL + "?" + params.Encode()
return Retry(t.retryConfig, func() (T, error) {
var result T
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
return result, fmt.Errorf("connection failed: %w", err)
}
defer conn.Close()
// Read messages until we get the one we want
for {
_, message, err := conn.ReadMessage()
if err != nil {
return result, fmt.Errorf("read error: %w", err)
}
if err := json.Unmarshal(message, &result); err != nil {
// log.Error("Failed to unmarshal message", "err", err)
continue
}
if t.handler(result) {
return result, nil
}
}
})
}

View File

@@ -1,286 +0,0 @@
package session
import (
"context"
"fmt"
"io"
"os"
"strings"
"sync"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/image"
"github.com/docker/docker/client"
)
// GPUType represents the type of GPU available
type GPUType int
const (
GPUNone GPUType = iota
GPUNvidia
GPUIntelAMD
)
// Session represents a Docker container session
type Session struct {
client *client.Client
containerID string
imageName string
config *SessionConfig
mu sync.RWMutex
isRunning bool
}
// SessionConfig holds the configuration for the session
type SessionConfig struct {
Room string
Resolution string
Framerate string
RelayURL string
Params string
GamePath string
}
// NewSession creates a new Docker session
func NewSession(config *SessionConfig) (*Session, error) {
cli, err := client.NewClientWithOpts(client.FromEnv)
if err != nil {
return nil, fmt.Errorf("failed to create Docker client: %v", err)
}
return &Session{
client: cli,
imageName: "archlinux", //"ghcr.io/datcaptainhorse/nestri-cachyos:latest-noavx2",
config: config,
}, nil
}
// Start initiates the Docker container session
func (s *Session) Start(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.isRunning {
return fmt.Errorf("session is already running")
}
// Detect GPU type
gpuType := detectGPU()
if gpuType == GPUNone {
return fmt.Errorf("no supported GPU detected")
}
// Get GPU-specific configurations
deviceRequests, err := getGPUDeviceRequests(gpuType)
if err != nil {
return err
}
devices := getGPUDevices(gpuType)
// Check if image exists locally
_, _, err = s.client.ImageInspectWithRaw(ctx, s.imageName)
if err != nil {
// Pull the image if it doesn't exist
reader, err := s.client.ImagePull(ctx, s.imageName, image.PullOptions{})
if err != nil {
return fmt.Errorf("failed to pull image: %v", err)
}
defer reader.Close()
// Copy pull output to stdout
io.Copy(os.Stdout, reader)
}
// Create container
resp, err := s.client.ContainerCreate(ctx, &container.Config{
Image: s.imageName,
Env: []string{
fmt.Sprintf("NESTRI_ROOM=%s", s.config.Room),
fmt.Sprintf("RESOLUTION=%s", s.config.Resolution),
fmt.Sprintf("NESTRI_PARAMS=%s", s.config.Params),
fmt.Sprintf("FRAMERATE=%s", s.config.Framerate),
fmt.Sprintf("RELAY_URL=%s", s.config.RelayURL),
},
}, &container.HostConfig{
Binds: []string{
fmt.Sprintf("%s:/home/nestri/.steam/", s.config.GamePath),
},
Resources: container.Resources{
DeviceRequests: deviceRequests,
Devices: devices,
},
SecurityOpt: []string{"label=disable"},
ShmSize: 5368709120, // 5GB
// ShmSize: 1073741824, // 1GB
}, nil, nil, "")
if err != nil {
return fmt.Errorf("failed to create container: %v", err)
}
// Start container
if err := s.client.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
return fmt.Errorf("failed to start container: %v", err)
}
// Store container ID and update state
s.containerID = resp.ID
s.isRunning = true
// Start logging in a goroutine
go s.streamLogs(ctx)
return nil
}
// Stop stops the Docker container session
func (s *Session) Stop(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.isRunning {
return fmt.Errorf("session is not running")
}
timeout := 30 // seconds
if err := s.client.ContainerStop(ctx, s.containerID, container.StopOptions{Timeout: &timeout}); err != nil {
return fmt.Errorf("failed to stop container: %v", err)
}
if err := s.client.ContainerRemove(ctx, s.containerID, container.RemoveOptions{}); err != nil {
return fmt.Errorf("failed to remove container: %v", err)
}
s.isRunning = false
s.containerID = ""
return nil
}
// IsRunning returns the current state of the session
func (s *Session) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.isRunning
}
// GetContainerID returns the current container ID
func (s *Session) GetContainerID() string {
s.mu.RLock()
defer s.mu.RUnlock()
return s.containerID
}
// streamLogs streams container logs to stdout
func (s *Session) streamLogs(ctx context.Context) {
opts := container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Follow: true,
}
logs, err := s.client.ContainerLogs(ctx, s.containerID, opts)
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting container logs: %v\n", err)
return
}
defer logs.Close()
_, err = io.Copy(os.Stdout, logs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error streaming logs: %v\n", err)
}
}
// VerifyEnvironment checks if all expected environment variables are set correctly in the container
func (s *Session) VerifyEnvironment(ctx context.Context) error {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.isRunning {
return fmt.Errorf("session is not running")
}
// Get container info to verify it's actually running
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
if err != nil {
return fmt.Errorf("failed to inspect container: %v", err)
}
if !inspect.State.Running {
return fmt.Errorf("container is not in running state")
}
// Expected environment variables
expectedEnv := map[string]string{
"NESTRI_ROOM": s.config.Room,
"RESOLUTION": s.config.Resolution,
"FRAMERATE": s.config.Framerate,
"RELAY_URL": s.config.RelayURL,
"NESTRI_PARAMS": s.config.Params,
}
// Get actual environment variables from container
containerEnv := make(map[string]string)
for _, env := range inspect.Config.Env {
parts := strings.SplitN(env, "=", 2)
if len(parts) == 2 {
containerEnv[parts[0]] = parts[1]
}
}
// Check each expected variable
var missingVars []string
var mismatchedVars []string
for key, expectedValue := range expectedEnv {
actualValue, exists := containerEnv[key]
if !exists {
missingVars = append(missingVars, key)
} else if actualValue != expectedValue {
mismatchedVars = append(mismatchedVars, fmt.Sprintf("%s (expected: %s, got: %s)",
key, expectedValue, actualValue))
}
}
// Build error message if there are any issues
if len(missingVars) > 0 || len(mismatchedVars) > 0 {
var errorMsg strings.Builder
if len(missingVars) > 0 {
errorMsg.WriteString(fmt.Sprintf("Missing environment variables: %s\n",
strings.Join(missingVars, ", ")))
}
if len(mismatchedVars) > 0 {
errorMsg.WriteString(fmt.Sprintf("Mismatched environment variables: %s",
strings.Join(mismatchedVars, ", ")))
}
return fmt.Errorf(errorMsg.String())
}
return nil
}
// GetEnvironment returns all environment variables in the container
func (s *Session) GetEnvironment(ctx context.Context) (map[string]string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.isRunning {
return nil, fmt.Errorf("session is not running")
}
inspect, err := s.client.ContainerInspect(ctx, s.containerID)
if err != nil {
return nil, fmt.Errorf("failed to inspect container: %v", err)
}
env := make(map[string]string)
for _, e := range inspect.Config.Env {
parts := strings.SplitN(e, "=", 2)
if len(parts) == 2 {
env[parts[0]] = parts[1]
}
}
return env, nil
}

View File

@@ -1,76 +0,0 @@
package session
import (
"bytes"
"context"
"fmt"
"io"
"strings"
"github.com/docker/docker/api/types/container"
)
// ExecResult holds the output from a container command
type ExecResult struct {
ExitCode int
Stdout string
Stderr string
}
func (s *Session) execInContainer(ctx context.Context, cmd []string) (*ExecResult, error) {
execConfig := container.ExecOptions{
Cmd: cmd,
AttachStdout: true,
AttachStderr: true,
}
execID, err := s.client.ContainerExecCreate(ctx, s.containerID, execConfig)
if err != nil {
return nil, err
}
resp, err := s.client.ContainerExecAttach(ctx, execID.ID, container.ExecAttachOptions{})
if err != nil {
return nil, err
}
defer resp.Close()
var outBuf bytes.Buffer
_, err = io.Copy(&outBuf, resp.Reader)
if err != nil {
return nil, err
}
inspect, err := s.client.ContainerExecInspect(ctx, execID.ID)
if err != nil {
return nil, err
}
return &ExecResult{
ExitCode: inspect.ExitCode,
Stdout: outBuf.String(),
}, nil
}
// CheckSteamGames returns the list of installed games in the container
func (s *Session) CheckInstalledSteamGames(ctx context.Context) ([]uint64, error) {
result, err := s.execInContainer(ctx, []string{
"sh", "-c",
"find /home/nestri/.steam/steam/steamapps -name '*.acf' -exec grep -H '\"appid\"' {} \\;",
})
if err != nil {
return nil, fmt.Errorf("failed to check steam games: %v", err)
}
var gameIDs []uint64
for _, line := range strings.Split(result.Stdout, "\n") {
if strings.Contains(line, "appid") {
var id uint64
if _, err := fmt.Sscanf(line, `"appid" "%d"`, &id); err == nil {
gameIDs = append(gameIDs, id)
}
}
}
return gameIDs, nil
}

View File

@@ -1,72 +0,0 @@
package session
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/docker/docker/api/types/container"
)
// detectGPU checks for available GPU type
func detectGPU() GPUType {
// First check for NVIDIA
cmd := exec.Command("nvidia-smi")
if err := cmd.Run(); err == nil {
return GPUNvidia
}
// Check for Intel/AMD GPU by looking for DRI devices
if _, err := os.Stat("/dev/dri"); err == nil {
return GPUIntelAMD
}
return GPUNone
}
// getGPUDeviceRequests returns appropriate device configuration based on GPU type
func getGPUDeviceRequests(gpuType GPUType) ([]container.DeviceRequest, error) {
switch gpuType {
case GPUNvidia:
return []container.DeviceRequest{
{
Driver: "nvidia",
Count: 1,
DeviceIDs: []string{"0"},
Capabilities: [][]string{{"gpu"}},
},
}, nil
case GPUIntelAMD:
return []container.DeviceRequest{}, nil // Empty as we'll handle this in Devices
default:
return nil, fmt.Errorf("no supported GPU detected")
}
}
// getGPUDevices returns appropriate device mappings based on GPU type
func getGPUDevices(gpuType GPUType) []container.DeviceMapping {
if gpuType == GPUIntelAMD {
devices := []container.DeviceMapping{}
// Only look for card and renderD nodes
for _, pattern := range []string{"card[0-9]*", "renderD[0-9]*"} {
matches, err := filepath.Glob(fmt.Sprintf("/dev/dri/%s", pattern))
if err != nil {
continue
}
for _, match := range matches {
// Verify it's a device file
if info, err := os.Stat(match); err == nil && (info.Mode()&os.ModeDevice) != 0 {
devices = append(devices, container.DeviceMapping{
PathOnHost: match,
PathInContainer: match,
CgroupPermissions: "rwm",
})
}
}
}
return devices
}
return nil
}

View File

@@ -1,58 +0,0 @@
package main
import (
"nestrilabs/cli/internal/party"
)
func main() {
// err := cmd.Execute()
// if err != nil {
// log.Error("Error running the cmd command", "err", err)
// }
// ctx := context.Background()
// config := &session.SessionConfig{
// Room: "victortest",
// Resolution: "1920x1080",
// Framerate: "60",
// RelayURL: "https://relay.dathorse.com",
// Params: "--verbose=true --video-codec=h264 --video-bitrate=4000 --video-bitrate-max=6000 --gpu-card-path=/dev/dri/card1",
// GamePath: "/path/to/your/game",
// }
// sess, err := session.NewSession(config)
// if err != nil {
// log.Error("Failed to create session", "err", err)
// }
// // Start the session
// if err := sess.Start(ctx); err != nil {
// log.Error("Failed to start session", "err", err)
// }
// // Check if it's running
// if sess.IsRunning() {
// log.Info("Session is running with container ID", "containerId", sess.GetContainerID())
// }
// env, err := sess.GetEnvironment(ctx)
// if err != nil {
// log.Printf("Failed to get environment: %v", err)
// } else {
// for key, value := range env {
// log.Info("Found this environment variables", key, value)
// }
// }
// // Let it run for a while
// // time.Sleep(time.Second * 50)
// // Stop the session
// if err := sess.Stop(ctx); err != nil {
// log.Error("Failed to stop session", "err", err)
// }
party := party.NewParty()
party.Connect()
}

View File

@@ -5,42 +5,53 @@ const _schema = i.schema({
$users: i.entity({
email: i.string().unique().indexed(),
}),
machines: i.entity({
// machines: i.entity({
// hostname: i.string(),
// fingerprint: i.string().unique().indexed(),
// deletedAt: i.date().optional().indexed(),
// createdAt: i.date()
// }),
tasks: i.entity({
type: i.string(),
lastStatus: i.string(),
healthStatus: i.string(),
startedAt: i.string(),
lastUpdated: i.date(),
stoppedAt: i.string().optional(),
taskID: i.string().unique().indexed()
}),
instances: i.entity({
hostname: i.string(),
fingerprint: i.string().unique().indexed(),
deletedAt: i.date().optional().indexed(),
lastActive: i.date().optional(),
createdAt: i.date()
}),
profiles: i.entity({
avatarUrl: i.string().optional(),
username: i.string().indexed(),
updatedAt: i.date(),
status: i.string().indexed(),
updatedAt: i.date().indexed(),
createdAt: i.date(),
discriminator: i.string().indexed()
}),
teams: i.entity({
name: i.string(),
slug: i.string().unique().indexed(),
deletedAt: i.date().optional().indexed(),
deletedAt: i.date().optional(),//.indexed(),
updatedAt: i.date(),
createdAt: i.date(),
}),
games: i.entity({
name: i.string(),
steamID: i.number().unique().indexed(),
}),
// games: i.entity({
// name: i.string(),
// steamID: i.number().unique().indexed(),
// }),
sessions: i.entity({
name: i.string(),
startedAt: i.date(),
endedAt: i.date().optional().indexed(),
public: i.boolean().indexed(),
}),
subscriptions: i.entity({
checkoutID: i.string(),
// quantity: i.number(),
// frequency: i.string(),
canceledAt: i.date(),
// next: i.date()
})
},
links: {
@@ -52,6 +63,18 @@ const _schema = i.schema({
forward: { on: "profiles", has: "one", label: "owner" },
reverse: { on: "$users", has: "one", label: "profile" }
},
UserTasks: {
forward: { on: "tasks", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "tasks" }
},
TaskSessions: {
forward: { on: "tasks", has: "many", label: "sessions" },
reverse: { on: "sessions", has: "one", label: "task" }
},
UserSession: {
forward: { on: "sessions", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "sessions" }
},
TeamsOwned: {
forward: { on: "teams", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "teamsOwned" },
@@ -60,30 +83,34 @@ const _schema = i.schema({
forward: { on: "teams", has: "many", label: "members" },
reverse: { on: "$users", has: "many", label: "teamsJoined" },
},
UserMachines: {
forward: { on: "machines", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "machines" }
},
UserGames: {
forward: { on: "games", has: "many", label: "owners" },
reverse: { on: "$users", has: "many", label: "games" }
},
MachineSessions: {
forward: { on: "machines", has: "many", label: "sessions" },
reverse: { on: "sessions", has: "one", label: "machine" }
},
GamesMachines: {
forward: { on: "machines", has: "many", label: "games" },
reverse: { on: "games", has: "many", label: "machines" }
},
GameSessions: {
forward: { on: "games", has: "many", label: "sessions" },
reverse: { on: "sessions", has: "one", label: "game" }
},
UserSessions: {
forward: { on: "sessions", has: "one", label: "owner" },
reverse: { on: "$users", has: "many", label: "sessions" }
}
// UserMachines: {
// forward: { on: "machines", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "machines" }
// },
// UserGames: {
// forward: { on: "games", has: "many", label: "owners" },
// reverse: { on: "$users", has: "many", label: "games" }
// },
// TeamInstances: {
// forward: { on: "instances", has: "many", label: "owners" },
// reverse: { on: "teams", has: "many", label: "instances" }
// },
// MachineSessions: {
// forward: { on: "machines", has: "many", label: "sessions" },
// reverse: { on: "sessions", has: "one", label: "machine" }
// },
// GamesMachines: {
// forward: { on: "machines", has: "many", label: "games" },
// reverse: { on: "games", has: "many", label: "machines" }
// },
// GameSessions: {
// forward: { on: "games", has: "many", label: "sessions" },
// reverse: { on: "sessions", has: "one", label: "game" }
// },
// UserSessions: {
// forward: { on: "sessions", has: "one", label: "owner" },
// reverse: { on: "$users", has: "many", label: "sessions" }
// }
}
});

View File

@@ -8,7 +8,10 @@
},
"devDependencies": {
"@tsconfig/node20": "^20.1.4",
"aws-iot-device-sdk-v2": "^1.21.1",
"aws4fetch": "^1.0.20",
"loops": "^3.4.1",
"mqtt": "^5.10.3",
"remeda": "^2.19.0",
"ulid": "^2.3.0",
"uuid": "^11.0.3",

View File

@@ -1,6 +1,6 @@
import { createContext } from "./context";
import { VisibleError } from "./error";
export interface UserActor {
type: "user";
properties: {
@@ -21,8 +21,8 @@ export interface UserActor {
export interface DeviceActor {
type: "device";
properties: {
fingerprint: string;
id: string;
teamSlug: string;
hostname: string;
auth?:
| {
type: "personal";
@@ -47,7 +47,7 @@ export function useCurrentUser() {
const actor = ActorContext.use();
if (actor.type === "user") return {
id:actor.properties.userID,
token: actor.properties.accessToken
token: actor.properties.accessToken,
};
throw new VisibleError(
@@ -60,8 +60,8 @@ export function useCurrentUser() {
export function useCurrentDevice() {
const actor = ActorContext.use();
if (actor.type === "device") return {
fingerprint:actor.properties.fingerprint,
id: actor.properties.id
hostname:actor.properties.hostname,
teamSlug: actor.properties.teamSlug
};
throw new VisibleError(
"auth",

View File

@@ -0,0 +1,90 @@
import { z } from "zod"
import { Resource } from "sst";
import { doubleFn, fn } from "../utils";
import { AwsClient } from "aws4fetch";
import { DescribeTasksCommandOutput, StopTaskCommandOutput, type RunTaskCommandOutput } from "@aws-sdk/client-ecs";
export module Aws {
export const client = async () => {
return new AwsClient({
accessKeyId: Resource.AwsAccessKey.value,
secretAccessKey: Resource.AwsSecretKey.value,
region: "us-east-1",
});
}
export const EcsRunTask = fn(z.object({
cluster: z.string(),
count: z.number(),
taskDefinition: z.string(),
launchType: z.enum(["EC2", "FARGATE"]),
overrides: z.object({
containerOverrides: z.object({
name: z.string(),
environment: z.object({
name: z.string(),
value: z.string().or(z.number())
}).array()
}).array()
})
}), async (body) => {
const c = await client();
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
const res = await c.fetch(url, {
method: "POST",
headers: {
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.RunTask",
"Content-Type": "application/x-amz-json-1.1",
},
body: JSON.stringify(body)
})
return await res.json() as RunTaskCommandOutput
})
export const EcsDescribeTasks = fn(z.object({ tasks: z.string().array(), cluster: z.string() }), async (body) => {
const c = await client();
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
const res = await c.fetch(url, {
method: "POST",
headers: {
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.DescribeTasks",
"Content-Type": "application/x-amz-json-1.1",
},
body: JSON.stringify(body)
})
return await res.json() as DescribeTasksCommandOutput
})
export const EcsStopTask = fn(z.object({
cluster: z.string().optional(),
reason: z.string().optional(),
task: z.string()
}), async (body) => {
const c = await client();
const url = new URL(`https://ecs.${c.region}.amazonaws.com/`)
const res = await c.fetch(url, {
method: "POST",
headers: {
"X-Amz-Target": "AmazonEC2ContainerServiceV20141113.StopTask",
"Content-Type": "application/x-amz-json-1.1",
},
body: JSON.stringify(body)
})
return await res.json() as StopTaskCommandOutput
})
}

View File

@@ -5,15 +5,27 @@ export module Examples {
email: "john@example.com",
};
export const Task = {
id: "0bfcc712-df13-4454-81a8-fbee66eddca4",
taskID: "b8302fca2d224d91ab342a2e4ab926d3",
type: "AWS" as const, //or "on-premises",
lastStatus: "RUNNING" as const,
healthStatus: "UNKNOWN" as const,
startedAt: '2025-01-09T01:56:23.902Z',
lastUpdated: '2025-01-09T01:56:23.902Z',
stoppedAt: '2025-01-09T04:46:23.902Z'
}
export const Profile = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
username: "janedoe47",
status: "active" as const,
avatarUrl: "https://cdn.discordapp.com/avatars/xxxxxxx/xxxxxxx.png",
discriminator: 12, //it needs to be two digits
createdAt: '2025-01-04T11:56:23.902Z',
updatedAt: '2025-01-09T01:56:23.902Z'
}
export const Subscription = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
checkoutID: "0bfcb712-df43-4454-81a8-fbee66eddca4",
@@ -23,10 +35,10 @@ export module Examples {
// next: '2025-01-09T01:56:23.902Z',
canceledAt: '2025-02-09T01:56:23.902Z'
}
export const Team = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
owner: true,
// owner: true,
name: "Jane Doe's Games",
slug: "jane-does-games",
createdAt: '2025-01-04T11:56:23.902Z',
@@ -41,6 +53,13 @@ export module Examples {
deletedAt: '2025-01-09T01:56:23.902Z'
}
export const Instance = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
hostname: "a955e059f05d",
createdAt: '2025-01-04T11:56:23.902Z',
lastActive: '2025-01-09T01:56:23.902Z'
}
export const Game = {
id: '0bfcb712-df13-4454-81a8-fbee66eddca4',
name: "Control Ultimate Edition",
@@ -50,8 +69,7 @@ export module Examples {
export const Session = {
id: "0bfcb712-df13-4454-81a8-fbee66eddca4",
public: true,
name: 'Late night chilling with the squad',
startedAt: '2025-01-04T11:56:23.902Z',
endedAt: '2025-01-04T11:56:23.902Z'
endedAt: '2025-01-04T12:36:23.902Z'
}
}

View File

@@ -1,151 +1,151 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { id as createID } from "@instantdb/admin";
import { groupBy, map, pipe, values } from "remeda"
import { useCurrentDevice, useCurrentUser } from "../actor";
// import { z } from "zod"
// import { fn } from "../utils";
// import { Common } from "../common";
// import { Examples } from "../examples";
// import databaseClient from "../database"
// import { id as createID } from "@instantdb/admin";
// import { groupBy, map, pipe, values } from "remeda"
// import { useCurrentDevice, useCurrentUser } from "../actor";
export module Games {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Game.id,
}),
name: z.string().openapi({
description: "A human-readable name for the game, used for easy identification.",
example: Examples.Game.name,
}),
steamID: z.number().openapi({
description: "The Steam ID of the game, used to identify it during installation and runtime.",
example: Examples.Game.steamID,
})
})
.openapi({
ref: "Game",
description: "Represents a Steam game that can be installed and played on a machine.",
example: Examples.Game,
});
// export module Games {
// export const Info = z
// .object({
// id: z.string().openapi({
// description: Common.IdDescription,
// example: Examples.Game.id,
// }),
// name: z.string().openapi({
// description: "A human-readable name for the game, used for easy identification.",
// example: Examples.Game.name,
// }),
// steamID: z.number().openapi({
// description: "The Steam ID of the game, used to identify it during installation and runtime.",
// example: Examples.Game.steamID,
// })
// })
// .openapi({
// ref: "Game",
// description: "Represents a Steam game that can be installed and played on a machine.",
// example: Examples.Game,
// });
export type Info = z.infer<typeof Info>;
// export type Info = z.infer<typeof Info>;
export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
const id = createID()
const db = databaseClient()
const device = useCurrentDevice()
// export const create = fn(Info.pick({ name: true, steamID: true }), async (input) => {
// const id = createID()
// const db = databaseClient()
// const device = useCurrentDevice()
await db.transact(
db.tx.games[id]!.update({
name: input.name,
steamID: input.steamID,
}).link({ machines: device.id })
)
// await db.transact(
// db.tx.games[id]!.update({
// name: input.name,
// steamID: input.steamID,
// }).link({ machines: device.id })
// )
// //
// return id
// })
return id
})
// export const list = async () => {
// const db = databaseClient()
// const user = useCurrentUser()
export const list = async () => {
const db = databaseClient()
const user = useCurrentUser()
// const query = {
// $users: {
// $: { where: { id: user.id } },
// games: {}
// },
// }
const query = {
$users: {
$: { where: { id: user.id } },
games: {}
},
}
// const res = await db.query(query)
const res = await db.query(query)
// const games = res.$users[0]?.games
// if (games && games.length > 0) {
// const result = pipe(
// games,
// groupBy(x => x.id),
// values(),
// map((group): Info => ({
// id: group[0].id,
// name: group[0].name,
// steamID: group[0].steamID,
// }))
// )
// return result
// }
// return null
// }
const games = res.$users[0]?.games
if (games && games.length > 0) {
const result = pipe(
games,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
steamID: group[0].steamID,
}))
)
return result
}
return null
}
// export const fromSteamID = fn(z.number(), async (steamID) => {
// const db = databaseClient()
export const fromSteamID = fn(z.number(), async (steamID) => {
const db = databaseClient()
// const query = {
// games: {
// $: {
// where: {
// steamID,
// }
// }
// }
// }
const query = {
games: {
$: {
where: {
steamID,
}
}
}
}
// const res = await db.query(query)
const res = await db.query(query)
// const games = res.games
const games = res.games
// if (games.length > 0) {
// const result = pipe(
// games,
// groupBy(x => x.id),
// values(),
// map((group): Info => ({
// id: group[0].id,
// name: group[0].name,
// steamID: group[0].steamID,
// }))
// )
// return result[0]
// }
if (games.length > 0) {
const result = pipe(
games,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
name: group[0].name,
steamID: group[0].steamID,
}))
)
return result[0]
}
// return null
// })
return null
})
// export const linkToCurrentUser = fn(z.string(), async (steamID) => {
// const user = useCurrentUser()
// const db = databaseClient()
export const linkToCurrentUser = fn(z.string(), async (steamID) => {
const user = useCurrentUser()
const db = databaseClient()
// await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
await db.transact(db.tx.games[steamID]!.link({ owners: user.id }))
// return "ok"
// })
return "ok"
})
// export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
// const user = useCurrentUser()
// const db = databaseClient()
export const unLinkFromCurrentUser = fn(z.number(), async (steamID) => {
const user = useCurrentUser()
const db = databaseClient()
// const query = {
// $users: {
// $: { where: { id: user.id } },
// games: {
// $: {
// where: {
// steamID,
// }
// }
// }
// },
// }
const query = {
$users: {
$: { where: { id: user.id } },
games: {
$: {
where: {
steamID,
}
}
}
},
}
// const res = await db.query(query)
// const games = res.$users[0]?.games
// if (games && games.length > 0) {
// const game = games[0] as Info
// await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
const res = await db.query(query)
const games = res.$users[0]?.games
if (games && games.length > 0) {
const game = games[0] as Info
await db.transact(db.tx.games[game.id]!.unlink({ owners: user.id }))
// return "ok"
// }
return "ok"
}
// return null
// })
return null
})
}
// }

View File

@@ -0,0 +1,83 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { id as createID } from "@instantdb/admin";
import { groupBy, map, pipe, values } from "remeda"
export module Instances {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Instance.id,
}),
hostname: z.string().openapi({
description: "The container's hostname",
example: Examples.Instance.hostname,
}),
createdAt: z.string().or(z.number()).openapi({
description: "The time this instances was registered on the network",
example: Examples.Instance.createdAt,
}),
lastActive: z.string().or(z.number()).optional().openapi({
description: "The time this instance was last seen on the network",
example: Examples.Instance.lastActive,
})
})
.openapi({
ref: "Instance",
description: "Represents a running container that is connected to the Nestri network..",
example: Examples.Instance,
});
export type Info = z.infer<typeof Info>;
export const create = fn(z.object({ hostname: z.string(), teamID: z.string() }), async (input) => {
const id = createID()
const now = new Date().toISOString()
const db = databaseClient()
await db.transact(
db.tx.instances[id]!.update({
hostname: input.hostname,
createdAt: now,
}).link({ owners: input.teamID })
)
return "ok"
})
export const fromTeamID = fn(z.string(), async (teamID) => {
const db = databaseClient()
const query = {
instances: {
$: {
where: {
owners: teamID
}
}
}
}
const res = await db.query(query)
const data = res.instances
if (data && data.length > 0) {
const result = pipe(
data,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
lastActive: group[0].lastActive,
hostname: group[0].hostname,
createdAt: group[0].createdAt
}))
)
return result
}
return null
})
}

View File

@@ -1,232 +1,232 @@
import { z } from "zod"
import { fn } from "../utils";
import { Common } from "../common";
import { Examples } from "../examples";
import { useCurrentUser } from "../actor";
import databaseClient from "../database"
import { id as createID } from "@instantdb/admin";
import { groupBy, map, pipe, values } from "remeda"
import { Games } from "../game"
export module Machines {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Machine.id,
}),
hostname: z.string().openapi({
description: "The Linux hostname that identifies this machine",
example: Examples.Machine.hostname,
}),
fingerprint: z.string().openapi({
description: "A unique identifier derived from the machine's Linux machine ID.",
example: Examples.Machine.fingerprint,
}),
createdAt: z.string().or(z.number()).openapi({
description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
example: Examples.Machine.createdAt,
})
})
.openapi({
ref: "Machine",
description: "Represents a physical or virtual machine connected to the Nestri network..",
example: Examples.Machine,
});
// import { z } from "zod"
// import { fn } from "../utils";
// import { Games } from "../game"
// import { Common } from "../common";
// import { Examples } from "../examples";
// import { useCurrentUser } from "../actor";
// import databaseClient from "../database"
// import { id as createID } from "@instantdb/admin";
// import { groupBy, map, pipe, values } from "remeda"
// export module Machines {
// export const Info = z
// .object({
// id: z.string().openapi({
// description: Common.IdDescription,
// example: Examples.Machine.id,
// }),
// hostname: z.string().openapi({
// description: "The Linux hostname that identifies this machine",
// example: Examples.Machine.hostname,
// }),
// fingerprint: z.string().openapi({
// description: "A unique identifier derived from the machine's Linux machine ID.",
// example: Examples.Machine.fingerprint,
// }),
// createdAt: z.string().or(z.number()).openapi({
// description: "Represents a machine running on the Nestri network, containing its identifying information and metadata.",
// example: Examples.Machine.createdAt,
// })
// })
// .openapi({
// ref: "Machine",
// description: "Represents a physical or virtual machine connected to the Nestri network..",
// example: Examples.Machine,
// });
export type Info = z.infer<typeof Info>;
// export type Info = z.infer<typeof Info>;
export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
const id = createID()
const now = new Date().toISOString()
const db = databaseClient()
await db.transact(
db.tx.machines[id]!.update({
fingerprint: input.fingerprint,
hostname: input.hostname,
createdAt: now,
//Just in case it had been previously deleted
deletedAt: undefined
})
)
// export const create = fn(Info.pick({ fingerprint: true, hostname: true }), async (input) => {
// const id = createID()
// const now = new Date().toISOString()
// const db = databaseClient()
// await db.transact(
// db.tx.machines[id]!.update({
// fingerprint: input.fingerprint,
// hostname: input.hostname,
// createdAt: now,
// //Just in case it had been previously deleted
// deletedAt: undefined
// })
// )
return id
})
// return id
// })
export const fromID = fn(z.string(), async (id) => {
const db = databaseClient()
// // export const fromID = fn(z.string(), async (id) => {
// const db = databaseClient()
const query = {
machines: {
$: {
where: {
id: id,
deletedAt: { $isNull: true }
}
}
}
}
// const query = {
// machines: {
// $: {
// where: {
// id: id,
// deletedAt: { $isNull: true }
// }
// }
// }
// }
const res = await db.query(query)
const machines = res.machines
// const res = await db.query(query)
// const machines = res.machines
if (machines && machines.length > 0) {
const result = pipe(
machines,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
fingerprint: group[0].fingerprint,
hostname: group[0].hostname,
createdAt: group[0].createdAt
}))
)
return result
}
// if (machines && machines.length > 0) {
// const result = pipe(
// machines,
// groupBy(x => x.id),
// values(),
// map((group): Info => ({
// id: group[0].id,
// fingerprint: group[0].fingerprint,
// hostname: group[0].hostname,
// createdAt: group[0].createdAt
// }))
// )
// return result
// }
return null
})
// return null
// })
export const installedGames = fn(z.string(), async (id) => {
const db = databaseClient()
// export const installedGames = fn(z.string(), async (id) => {
// const db = databaseClient()
const query = {
machines: {
$: {
where: {
id: id,
deletedAt: { $isNull: true }
}
},
games: {}
}
}
// const query = {
// machines: {
// $: {
// where: {
// id: id,
// deletedAt: { $isNull: true }
// }
// },
// games: {}
// }
// }
const res = await db.query(query)
const machines = res.machines
// const res = await db.query(query)
// const machines = res.machines
if (machines && machines.length > 0) {
const games = machines[0]?.games as any
if (games.length > 0) {
return games as Games.Info[]
}
return null
}
// if (machines && machines.length > 0) {
// const games = machines[0]?.games as any
// if (games.length > 0) {
// return games as Games.Info[]
// }
// return null
// }
return null
})
// return null
// })
export const fromFingerprint = fn(z.string(), async (input) => {
const db = databaseClient()
// export const fromFingerprint = fn(z.string(), async (input) => {
// const db = databaseClient()
const query = {
machines: {
$: {
where: {
fingerprint: input,
deletedAt: { $isNull: true }
}
}
}
}
// const query = {
// machines: {
// $: {
// where: {
// fingerprint: input,
// deletedAt: { $isNull: true }
// }
// }
// }
// }
const res = await db.query(query)
// const res = await db.query(query)
const machines = res.machines
// const machines = res.machines
if (machines.length > 0) {
const result = pipe(
machines,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
fingerprint: group[0].fingerprint,
hostname: group[0].hostname,
createdAt: group[0].createdAt
}))
)
return result[0]
}
// if (machines.length > 0) {
// const result = pipe(
// machines,
// groupBy(x => x.id),
// values(),
// map((group): Info => ({
// id: group[0].id,
// fingerprint: group[0].fingerprint,
// hostname: group[0].hostname,
// createdAt: group[0].createdAt
// }))
// )
// return result[0]
// }
return null
})
// return null
// })
export const list = async () => {
const user = useCurrentUser()
const db = databaseClient()
// export const list = async () => {
// const user = useCurrentUser()
// const db = databaseClient()
const query = {
$users: {
$: { where: { id: user.id } },
machines: {
$: {
where: {
deletedAt: { $isNull: true }
}
}
}
},
}
// const query = {
// $users: {
// $: { where: { id: user.id } },
// machines: {
// $: {
// where: {
// deletedAt: { $isNull: true }
// }
// }
// }
// },
// }
const res = await db.query(query)
// const res = await db.query(query)
const machines = res.$users[0]?.machines
if (machines && machines.length > 0) {
const result = pipe(
machines,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
fingerprint: group[0].fingerprint,
hostname: group[0].hostname,
createdAt: group[0].createdAt
}))
)
return result
}
return null
}
// const machines = res.$users[0]?.machines
// if (machines && machines.length > 0) {
// const result = pipe(
// machines,
// groupBy(x => x.id),
// values(),
// map((group): Info => ({
// id: group[0].id,
// fingerprint: group[0].fingerprint,
// hostname: group[0].hostname,
// createdAt: group[0].createdAt
// }))
// )
// return result
// }
// return null
// }
export const linkToCurrentUser = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient()
// export const linkToCurrentUser = fn(z.string(), async (id) => {
// const user = useCurrentUser()
// const db = databaseClient()
await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
// await db.transact(db.tx.machines[id]!.link({ owner: user.id }))
return "ok"
})
// return "ok"
// })
export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient()
const now = new Date().toISOString()
// export const unLinkFromCurrentUser = fn(z.string(), async (id) => {
// const user = useCurrentUser()
// const db = databaseClient()
// const now = new Date().toISOString()
const query = {
$users: {
$: { where: { id: user.id } },
machines: {
$: {
where: {
id,
deletedAt: { $isNull: true }
}
}
}
},
}
// const query = {
// $users: {
// $: { where: { id: user.id } },
// machines: {
// $: {
// where: {
// id,
// deletedAt: { $isNull: true }
// }
// }
// }
// },
// }
const res = await db.query(query)
const machines = res.$users[0]?.machines
if (machines && machines.length > 0) {
const machine = machines[0] as Info
await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
// const res = await db.query(query)
// const machines = res.$users[0]?.machines
// if (machines && machines.length > 0) {
// const machine = machines[0] as Info
// await db.transact(db.tx.machines[machine.id]!.update({ deletedAt: now }))
return "ok"
}
// return "ok"
// }
return null
})
// return null
// })
}
// }

View File

@@ -4,9 +4,15 @@ import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database";
import { groupBy, map, pipe, values } from "remeda"
import { id as createID } from "@instantdb/admin";
import { id as createID, } from "@instantdb/admin";
import { useCurrentUser } from "../actor";
export const userStatus = z.enum([
"active", //online and playing a game
"idle", //online and not playing
"offline",
]);
export module Profiles {
const MAX_ATTEMPTS = 50;
@@ -24,6 +30,10 @@ export module Profiles {
description: "The url to the profile picture.",
example: Examples.Profile.username,
}),
status: userStatus.openapi({
description: "Whether the user is active, idle or offline",
example: Examples.Profile.status
}),
discriminator: z.string().or(z.number()).openapi({
description: "The number discriminator for each username",
example: Examples.Profile.discriminator,
@@ -44,6 +54,7 @@ export module Profiles {
});
export type Info = z.infer<typeof Info>;
export type userStatus = z.infer<typeof userStatus>;
export const sanitizeUsername = (username: string): string => {
// Remove spaces and numbers
@@ -91,7 +102,8 @@ export module Profiles {
username: group[0].username,
createdAt: group[0].createdAt,
discriminator: group[0].discriminator,
updatedAt: group[0].updatedAt
updatedAt: group[0].updatedAt,
status: group[0].status as userStatus
}))
)
})
@@ -175,6 +187,7 @@ export module Profiles {
createdAt: now,
updatedAt: now,
discriminator,
status: "idle"
}).link({ owner: input.owner })
)
})
@@ -203,48 +216,197 @@ export module Profiles {
return `${profiles[0]?.username}#${profiles[0]?.discriminator}`;
}
export const getProfile = async (ownerID: string) => {
export const fromOwnerID = async (ownerID: string) => {
try {
const db = databaseClient()
const db = databaseClient()
const query = {
profiles: {
$: {
where: {
owner: ownerID
}
},
const query = {
profiles: {
$: {
where: {
owner: ownerID
}
},
}
}
}
const res = await db.query(query)
const res = await db.query(query)
const profiles = res.profiles
const profiles = res.profiles
if (!profiles || profiles.length === 0) {
if (!profiles || profiles.length === 0) {
throw new Error("No profiles were found");
}
const profile = pipe(
profiles,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
username: group[0].username,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
avatarUrl: group[0].avatarUrl,
discriminator: group[0].discriminator,
status: group[0].status as userStatus
}))
)
return profile[0]
} catch (error) {
console.log("user fromOwnerID", error)
return null
}
const profile = pipe(
profiles,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
username: group[0].username,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
avatarUrl: group[0].avatarUrl,
discriminator: group[0].discriminator
}))
)
return profile[0]
}
export const fromID = async (id: string) => {
try {
const db = databaseClient()
const query = {
profiles: {
$: {
where: {
id
}
},
}
}
const res = await db.query(query)
const profiles = res.profiles
if (!profiles || profiles.length === 0) {
throw new Error("No profiles were found");
}
const profile = pipe(
profiles,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
username: group[0].username,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
avatarUrl: group[0].avatarUrl,
discriminator: group[0].discriminator,
status: group[0].status as userStatus
}))
)
return profile[0]
} catch (error) {
console.log("user fromID", error)
return null
}
}
export const fromIDToOwner = async (id: string) => {
try {
const db = databaseClient()
const query = {
profiles: {
$: {
where: {
id
}
},
}
}
const res = await db.query(query)
const profiles = res.profiles as any
if (!profiles || profiles.length === 0) {
throw new Error("No profiles were found");
}
return profiles[0]!.owner as string
} catch (error) {
console.log("user fromID", error)
return null
}
}
export const getCurrentProfile = async () => {
const user = useCurrentUser()
const currentProfile = await getProfile(user.id);
const currentProfile = await fromOwnerID(user.id);
return currentProfile
}
};
export const setStatus = fn(userStatus, async (status) => {
try {
const user = useCurrentUser()
const db = databaseClient()
const now = new Date().toISOString()
await db.transact(
db.tx.profiles[user.id]!.update({
status,
updatedAt: now
})
)
} catch (error) {
console.log("user setStatus error", error)
return null
}
})
export const list = async () => {
try {
const db = databaseClient()
// const ago = new Date(Date.now() - (60 * 1000 * 30)).toISOString()
const ago = new Date(Date.now() - (24 * 60 * 60 * 1000)).toISOString()
const query = {
profiles: {
$: {
limit: 10,
where: {
updatedAt: { $gt: ago },
},
order: {
updatedAt: "desc" as const,
},
}
}
}
const res = await db.query(query)
const profiles = res.profiles
if (!profiles || profiles.length === 0) {
throw new Error("No profiles were found");
}
const result = pipe(
profiles,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
username: group[0].username,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
avatarUrl: group[0].avatarUrl,
discriminator: group[0].discriminator,
status: group[0].status as userStatus
}))
)
return result
} catch (error) {
console.log("user list error", error)
return null
}
}
}

View File

@@ -1,6 +1,5 @@
import { z } from "zod"
import { fn } from "../utils";
import { Machines } from "../machine";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
@@ -15,10 +14,6 @@ export module Sessions {
description: Common.IdDescription,
example: Examples.Session.id,
}),
name: z.string().openapi({
description: "A human-readable name for the session to help identify it",
example: Examples.Session.name,
}),
public: z.boolean().openapi({
description: "If true, the session is publicly viewable by all users. If false, only authorized users can access it",
example: Examples.Session.public,
@@ -40,82 +35,31 @@ export module Sessions {
export type Info = z.infer<typeof Info>;
export const create = fn(z.object({ name: z.string(), public: z.boolean(), fingerprint: z.string(), steamID: z.number() }), async (input) => {
const id = createID()
const now = new Date().toISOString()
const db = databaseClient()
const user = useCurrentUser()
const machine = await Machines.fromFingerprint(input.fingerprint)
if (!machine) {
return { error: "Such a machine does not exist" }
export const create = fn(z.object({ public: z.boolean() }), async (input) => {
try {
const id = createID()
const db = databaseClient()
const user = useCurrentUser()
const now = new Date().toISOString()
await db.transact(
db.tx.sessions[id]!.update({
public: input.public,
startedAt: now,
}).link({ owner: user.id })
)
return id
} catch (err) {
return null
}
const games = await Machines.installedGames(machine.id)
if (!games) {
return { error: "The machine has no installed games" }
}
const result = pipe(
games,
groupBy(x => x.steamID === input.steamID ? "similar" : undefined),
)
if (!result.similar || result.similar.length == 0) {
return { error: "The machine does not have this game installed" }
}
await db.transact(
db.tx.sessions[id]!.update({
name: input.name,
public: input.public,
startedAt: now,
}).link({ owner: user.id, machine: machine.id, game: result.similar[0].id })
)
return { data: id }
})
export const list = async () => {
const user = useCurrentUser()
const db = databaseClient()
const query = {
$users: {
$: { where: { id: user.id } },
sessions: {}
},
}
const res = await db.query(query)
const sessions = res.$users[0]?.sessions
if (sessions && sessions.length > 0) {
const result = pipe(
sessions,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
name: group[0].name
}))
)
return result
}
return null
}
export const getActive = async () => {
const user = useCurrentUser()
const db = databaseClient()
try {
const db = databaseClient()
const query = {
$users: {
$: { where: { id: user.id } },
const query = {
sessions: {
$: {
where: {
@@ -123,48 +67,15 @@ export module Sessions {
}
}
}
},
}
const res = await db.query(query)
const sessions = res.$users[0]?.sessions
if (sessions && sessions.length > 0) {
const result = pipe(
sessions,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
name: group[0].name
}))
)
return result
}
return null
}
export const getPublicActive = async () => {
const db = databaseClient()
const query = {
sessions: {
$: {
where: {
endedAt: { $isNull: true },
public: true
}
}
}
}
const res = await db.query(query)
const res = await db.query(query)
const sessions = res.sessions
if (!sessions || sessions.length === 0) {
throw new Error("No active sessions found")
}
const sessions = res.sessions
if (sessions && sessions.length > 0) {
const result = pipe(
sessions,
groupBy(x => x.id),
@@ -174,39 +85,37 @@ export module Sessions {
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
name: group[0].name
}))
)
return result
} catch (error) {
return null
}
return null
}
export const fromSteamID = fn(z.number(), async (steamID) => {
const db = databaseClient()
export const fromID = fn(z.string(), async (id) => {
try {
const db = databaseClient()
const query = {
games: {
$: {
where: {
steamID
}
},
const query = {
sessions: {
$: {
where: {
endedAt: { $isNull: true },
public: true
id: id,
}
}
}
}
}
const res = await db.query(query)
const res = await db.query(query)
const sessions = res.sessions
if (!sessions || sessions.length === 0) {
throw new Error("No sessions were found");
}
const sessions = res.games[0]?.sessions
if (sessions && sessions.length > 0) {
const result = pipe(
sessions,
groupBy(x => x.id),
@@ -216,32 +125,38 @@ export module Sessions {
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
name: group[0].name
}))
)
return result
} catch (err) {
console.log("sessions error", err)
return null
}
return null
})
export const fromID = fn(z.string(), async (id) => {
const db = databaseClient()
useCurrentUser()
export const fromTaskID = fn(z.string(), async (taskID) => {
try {
const db = databaseClient()
const query = {
sessions: {
$: {
where: {
id: id,
const query = {
sessions: {
$: {
where: {
task: taskID,
endedAt: { $isNull: true }
}
}
}
}
}
const res = await db.query(query)
const sessions = res.sessions
const res = await db.query(query)
const sessions = res.sessions
if (!sessions || sessions.length === 0) {
throw new Error("No sessions were found");
}
console.log("sessions", sessions)
if (sessions && sessions.length > 0) {
const result = pipe(
sessions,
groupBy(x => x.id),
@@ -251,42 +166,86 @@ export module Sessions {
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
name: group[0].name
}))
)
return result
return result[0]
} catch (err) {
console.log("sessions error", err)
return null
}
return null
})
export const end = fn(z.string(), async (id) => {
const user = useCurrentUser()
const db = databaseClient()
const now = new Date().toISOString()
try {
const db = databaseClient()
const now = new Date().toISOString()
const query = {
$users: {
$: { where: { id: user.id } },
const query = {
sessions: {
$: {
where: {
owner: user.id,
id,
}
}
}
},
}
},
}
const res = await db.query(query)
const sessions = res.$users[0]?.sessions
if (sessions && sessions.length > 0) {
const session = sessions[0] as Info
await db.transact(db.tx.sessions[session.id]!.update({ endedAt: now }))
const res = await db.query(query)
const sessions = res.sessions
if (!sessions || sessions.length === 0) {
throw new Error("No sessions were found");
}
await db.transact(db.tx.sessions[sessions[0]!.id]!.update({ endedAt: now }))
return "ok"
} catch (error) {
return null
}
return null
})
export const fromOwnerID = fn(z.string(), async (id) => {
try {
const db = databaseClient()
const query = {
sessions: {
$: {
where: {
owner: id,
endedAt: { $isNull: true }
}
}
}
}
const res = await db.query(query)
const sessions = res.sessions
if (!sessions || sessions.length === 0) {
throw new Error("No sessions were found");
}
const result = pipe(
sessions,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
endedAt: group[0].endedAt,
startedAt: group[0].startedAt,
public: group[0].public,
}))
)
return result[0]
} catch (err) {
console.log("session owner error", err)
return null
}
})
}

View File

@@ -59,15 +59,15 @@ export namespace Subscriptions {
export type Info = z.infer<typeof Info>;
export const list = async () => {
export const list = fn(z.string().optional(), async (userID) => {
const db = databaseClient()
const user = useCurrentUser()
const user = userID ? userID : useCurrentUser().id
const query = {
subscriptions: {
$: {
where: {
owner: user.id,
owner: user,
canceledAt: { $isNull: true }
}
},
@@ -96,7 +96,7 @@ export namespace Subscriptions {
)
return result
}
})
export const create = fn(Info.omit({ id: true, canceledAt: true }), async (input) => {
// const id = createID()
@@ -112,7 +112,7 @@ export namespace Subscriptions {
checkoutID: input.checkoutID,
}).link({ owner: user.id }))
const res = await db.auth.getUser({ id: user.id })
const profile = await Profiles.getProfile(user.id)
const profile = await Profiles.fromOwnerID(user.id)
if (profile) {
await Email.sendWelcome(res.email, profile.username)
}
@@ -123,7 +123,7 @@ export namespace Subscriptions {
const db = databaseClient()
await db.transact(db.tx.subscriptions[id]!.update({
canceledAt: new Date().toString()
canceledAt: new Date().toISOString()
}))
})

View File

@@ -0,0 +1,331 @@
import { z } from "zod";
import { fn } from "../utils";
import { Resource } from "sst";
import { Aws } from "../aws/client";
import { Common } from "../common";
import { Examples } from "../examples";
import databaseClient from "../database"
import { useCurrentUser } from "../actor";
import { id as createID } from "@instantdb/admin";
import { groupBy, map, pipe, values } from "remeda"
import { Sessions } from "../session";
export const lastStatus = z.enum([
"RUNNING",
"PENDING",
"UNKNOWN",
"STOPPED",
]);
export const taskType = z.enum([
"AWS",
"ON_PREMISES",
"UNKNOWN"
]);
export const healthStatus = z.enum([
"HEALTHY",
"UNHEALTHY",
"UNKNOWN",
]);
export type taskType = z.infer<typeof taskType>;
export type lastStatus = z.infer<typeof lastStatus>;
export type healthStatus = z.infer<typeof healthStatus>;
export module Tasks {
export const Info = z
.object({
id: z.string().openapi({
description: Common.IdDescription,
example: Examples.Task.id,
}),
type: taskType.openapi({
description: "Where this task is hosted on",
example: Examples.Task.type,
}),
taskID: z.string().openapi({
description: "The id of this task as seen on AWS",
example: Examples.Task.taskID,
}),
startedAt: z.string().or(z.number()).openapi({
description: "The time this task was started",
example: Examples.Task.startedAt,
}),
lastUpdated: z.string().or(z.number()).openapi({
description: "The time the information about this task was last updated",
example: Examples.Task.lastUpdated,
}),
stoppedAt: z.string().or(z.number()).optional().openapi({
description: "The time this task was stopped or quit",
example: Examples.Task.lastUpdated,
}),
lastStatus: lastStatus.openapi({
description: "The last registered status of this task",
example: Examples.Task.lastStatus,
}),
healthStatus: healthStatus.openapi({
description: "The health status of this task",
example: Examples.Task.healthStatus,
})
})
.openapi({
ref: "Subscription",
description: "Subscription to a Nestri product.",
example: Examples.Task,
});
export type Info = z.infer<typeof Info>;
export const list = async () => {
const db = databaseClient()
const user = useCurrentUser()
try {
const query = {
tasks: {
$: {
where: {
stoppedAt: { $isNull: true },
owner: user.id
}
},
}
}
const data = await db.query(query)
const response = data.tasks
if (!response || response.length === 0) {
throw new Error("No task for this user were found");
}
const result = pipe(
response,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
taskID: group[0].taskID,
type: group[0].type as taskType,
lastStatus: group[0].lastStatus as lastStatus,
healthStatus: group[0].healthStatus as healthStatus,
startedAt: group[0].startedAt,
stoppedAt: group[0].stoppedAt,
lastUpdated: group[0].lastUpdated,
}))
)
return result
} catch (e) {
return null
}
}
export const create = async () => {
const user = useCurrentUser()
try {
//TODO: Use a simpler way to set the session ID
// const sessionID = createID()
const sessionID = await Sessions.create({ public: true })
if (!sessionID) throw new Error("No session id was given");
const run = await Aws.EcsRunTask({
count: 1,
cluster: Resource.NestriGPUCluster.value,
taskDefinition: Resource.NestriGPUTask.value,
launchType: "EC2",
overrides: {
containerOverrides: [
{
name: "nestri",
environment: [
{
name: "NESTRI_ROOM",
value: sessionID
}
]
}
]
}
})
if (!run.tasks || run.tasks.length === 0) {
throw new Error(`No tasks were started`);
}
// Extract task details
const task = run.tasks[0];
const taskArn = task?.taskArn!;
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
const taskStatus = task?.lastStatus;
const taskHealthStatus = task?.healthStatus;
const startedAt = task?.startedAt!;
const id = createID()
const db = databaseClient()
const now = new Date().toISOString()
await db.transact(db.tx.tasks[id]!.update({
taskID: taskId,
type: "AWS",
healthStatus: taskHealthStatus ? taskHealthStatus.toString() : "UNKNOWN",
startedAt: startedAt ? startedAt.toISOString() : now,
lastStatus: taskStatus,
lastUpdated: now,
}).link({ owner: user.id, sessions: sessionID }))
return id
} catch (e) {
console.error("error", e)
return null
}
}
export const fromID = fn(z.string(), async (taskID) => {
const db = databaseClient()
try {
const query = {
tasks: {
$: {
where: {
id: taskID,
stoppedAt: { $isNull: true }
}
},
}
}
const data = await db.query(query)
const response = data.tasks
if (!response || response.length === 0) {
throw new Error("No task with the given id was found");
}
const result = pipe(
response,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
taskID: group[0].taskID,
type: group[0].type as taskType,
lastStatus: group[0].lastStatus as lastStatus,
healthStatus: group[0].healthStatus as healthStatus,
startedAt: group[0].startedAt,
stoppedAt: group[0].stoppedAt,
lastUpdated: group[0].lastUpdated,
}))
)
return result[0]
} catch (error) {
return null
}
})
export const update = fn(z.string(), async (taskID) => {
try {
const db = databaseClient()
const query = {
tasks: {
$: {
where: {
id: taskID,
stoppedAt: { $isNull: true }
}
},
}
}
const data = await db.query(query)
const response = data.tasks
if (!response || response.length === 0) {
throw new Error("No task with the given taskID was found");
}
const now = new Date().toISOString()
const describeResponse = await Aws.EcsDescribeTasks({
tasks: [response[0]!.taskID],
cluster: Resource.NestriGPUCluster.value
})
if (!describeResponse.tasks || describeResponse.tasks.length === 0) {
throw new Error("No tasks were found");
}
const task = describeResponse.tasks[0]!
const updatedDb = {
healthStatus: task.healthStatus ? task.healthStatus : "UNKNOWN",
lastStatus: task.lastStatus ? task.lastStatus : "UNKNOWN",
lastUpdated: now,
}
await db.transact(db.tx.tasks[response[0]!.id]!.update({
...updatedDb
}))
const updatedRes = [{ ...response[0]!, ...updatedDb }]
const result = pipe(
updatedRes,
groupBy(x => x.id),
values(),
map((group): Info => ({
id: group[0].id,
taskID: group[0].taskID,
type: group[0].type as taskType,
lastStatus: group[0].lastStatus as lastStatus,
healthStatus: group[0].healthStatus as healthStatus,
startedAt: group[0].startedAt,
stoppedAt: group[0].stoppedAt,
lastUpdated: group[0].lastUpdated,
}))
)
return result
} catch (error) {
console.error("update error", error)
return null
}
})
export const stop = fn(z.object({ taskID: z.string(), id: z.string() }), async (input) => {
const db = databaseClient()
const now = new Date().toISOString()
try {
//TODO:Check whether they own this task first
const stopResponse = await Aws.EcsStopTask({
task: input.taskID,
cluster: Resource.NestriGPUCluster.value,
reason: "Client requested a shutdown"
})
if (!stopResponse.task) {
throw new Error(`No task was stopped`);
}
await db.transact(db.tx.tasks[input.id]!.update({
stoppedAt: now,
lastUpdated: now,
lastStatus: "STOPPED",
healthStatus: "UNKNOWN"
}))
return "ok"
} catch (error) {
console.error("stop error", error)
return null
}
})
}

View File

@@ -26,10 +26,10 @@ export namespace Teams {
description: "The time when this team was last edited",
example: Examples.Team.updatedAt,
}),
owner: z.boolean().openapi({
description: "Whether this team is owned by this user",
example: Examples.Team.owner,
}),
// owner: z.boolean().openapi({
// description: "Whether this team is owned by this user",
// example: Examples.Team.owner,
// }),
slug: z.string().openapi({
description: "This is the unique name identifier for the team",
example: Examples.Team.slug
@@ -112,11 +112,10 @@ export namespace Teams {
map((group): Info => ({
id: group[0].id,
name: group[0].name,
createdAt: group[0].createdAt,
slug: group[0].slug,
createdAt: group[0].createdAt,
updatedAt: group[0].updatedAt,
//@ts-expect-error
owner: group[0].owner === user.id
// owner: group[0].owner === user.id
}))
)

View File

@@ -10,4 +10,18 @@ export function fn<
};
result.schema = arg1;
return result;
}
export function doubleFn<
Arg1 extends ZodSchema,
Arg2 extends ZodSchema,
Callback extends (arg1: z.output<Arg1>, arg2: z.output<Arg2>) => any,
>(arg1: Arg1, arg2: Arg2, cb: Callback) {
const result = function (input: z.input<typeof arg1>, input2: z.input<typeof arg2>): ReturnType<Callback> {
const parsed = arg1.parse(input);
const parsed2 = arg2.parse(input2);
return cb.apply(cb, [parsed as any, parsed2 as any]);
};
result.schema = arg1;
return result;
}

View File

@@ -2,57 +2,8 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientID": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}
export {}

View File

@@ -3,10 +3,11 @@
"module": "index.ts",
"type": "module",
"devDependencies": {
"@aws-sdk/client-ecs": "^3.738.0",
"@aws-sdk/client-sqs": "^3.734.0",
"@cloudflare/workers-types": "^4.20241224.0",
"@nestri/core": "*",
"@types/bun": "latest",
"partykit": "^0.0.111",
"valibot": "^1.0.0-beta.9"
},
"peerDependencies": {

View File

@@ -1,6 +0,0 @@
{
"$schema": "https://www.partykit.io/schema.json",
"name": "nestri-party",
"main": "src/party/index.ts",
"compatibilityDate": "2024-12-31"
}

View File

@@ -1,264 +1,264 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Games } from "@nestri/core/game/index";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Sessions } from "@nestri/core/session/index";
// import { z } from "zod";
// import { Hono } from "hono";
// import { Result } from "../common";
// import { describeRoute } from "hono-openapi";
// import { Games } from "@nestri/core/game/index";
// import { Examples } from "@nestri/core/examples";
// import { validator, resolver } from "hono-openapi/zod";
// import { Sessions } from "@nestri/core/session/index";
export module GameApi {
export const route = new Hono()
.get(
"/",
//FIXME: Add a way to filter through query params
describeRoute({
tags: ["Game"],
summary: "Retrieve all games in the user's library",
description: "Returns a list of all (known) games associated with the authenticated user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Games.Info.array().openapi({
description: "A list of games owned by the user",
example: [Examples.Game],
}),
),
},
},
description: "Successfully retrieved the user's library of games",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No games were found in the authenticated user's library",
},
},
}),
async (c) => {
const games = await Games.list();
if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
return c.json({ data: games }, 200);
},
)
.get(
"/:steamID",
describeRoute({
tags: ["Game"],
summary: "Retrieve a game by its Steam ID",
description: "Fetches detailed metadata about a specific game using its Steam ID",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No game found matching the provided Steam ID",
},
200: {
content: {
"application/json": {
schema: Result(
Games.Info.openapi({
description: "Detailed metadata about the requested game",
example: Examples.Game,
}),
),
},
},
description: "Successfully retrieved game metadata",
},
},
}),
validator(
"param",
z.object({
steamID: Games.Info.shape.steamID.openapi({
description: "The unique Steam ID used to identify a game",
example: Examples.Game.steamID,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const game = await Games.fromSteamID(params.steamID);
if (!game) return c.json({ error: "Game not found" }, 404);
return c.json({ data: game }, 200);
},
)
.post(
"/:steamID",
describeRoute({
tags: ["Game"],
summary: "Add a game to the user's library using its Steam ID",
description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "Game successfully added to user's library",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No game was found matching the provided Steam ID",
},
},
}),
validator(
"param",
z.object({
steamID: Games.Info.shape.steamID.openapi({
description: "The unique Steam ID of the game to be added to the current user's library",
example: Examples.Game.steamID,
}),
}),
),
async (c) => {
const params = c.req.valid("param")
const game = await Games.fromSteamID(params.steamID)
if (!game) return c.json({ error: "Game not found" }, 404);
const res = await Games.linkToCurrentUser(game.id)
return c.json({ data: res }, 200);
},
)
.delete(
"/:steamID",
describeRoute({
tags: ["Game"],
summary: "Remove game from user's library",
description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Game successfully removed from library",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The game with the specified Steam ID was not found",
},
}
}),
validator(
"param",
z.object({
steamID: Games.Info.shape.steamID.openapi({
description: "The Steam ID of the game to be removed",
example: Examples.Game.steamID,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const res = await Games.unLinkFromCurrentUser(params.steamID)
if (!res) return c.json({ error: "Game not found the library" }, 404);
return c.json({ data: res }, 200);
},
)
.put(
"/",
describeRoute({
tags: ["Game"],
summary: "Update game metadata",
description: "Updates the metadata about a specific game using its Steam ID",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Game successfully updated",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The game with the specified Steam ID was not found",
},
}
}),
validator(
"json",
Games.Info.omit({ id: true }).openapi({
description: "Game information",
//@ts-expect-error
example: { ...Examples.Game, id: undefined }
})
),
async (c) => {
const params = c.req.valid("json");
const res = await Games.create(params)
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
return c.json({ data: res }, 200);
},
)
.get(
"/:steamID/sessions",
describeRoute({
tags: ["Game"],
summary: "Retrieve game sessions by the associated game's Steam ID",
description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "This game does not have nay publicly active sessions",
},
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.array().openapi({
description: "Publicly active sessions associated with the game",
example: [Examples.Session],
}),
),
},
},
description: "Successfully retrieved game sessions associated with this game",
},
},
}),
validator(
"param",
z.object({
steamID: Games.Info.shape.steamID.openapi({
description: "The unique Steam ID used to identify a game",
example: Examples.Game.steamID,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const sessions = await Sessions.fromSteamID(params.steamID);
if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
return c.json({ data: sessions }, 200);
},
);
}
// export module GameApi {
// export const route = new Hono()
// .get(
// "/",
// //FIXME: Add a way to filter through query params
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve all games in the user's library",
// description: "Returns a list of all (known) games associated with the authenticated user",
// responses: {
// 200: {
// content: {
// // "application/json": {
// schema: Result(
// Games.Info.array().openapi({
// description: "A list of games owned by the user",
// example: [Examples.Game],
// }),
// ),
// },
// },
// description: "Successfully retrieved the user's library of games",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No games were found in the authenticated user's library",
// },
// },
// }),
// async (c) => {
// const games = await Games.list();
// if (!games) return c.json({ error: "No games exist in this user's library" }, 404);
// return c.json({ data: games }, 200);
// },
// )
// .get(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve a game by its Steam ID",
// description: "Fetches detailed metadata about a specific game using its Steam ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No game found matching the provided Steam ID",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Games.Info.openapi({
// description: "Detailed metadata about the requested game",
// example: Examples.Game,
// }),
// ),
// },
// },
// description: "Successfully retrieved game metadata",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID used to identify a game",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const game = await Games.fromSteamID(params.steamID);
// if (!game) return c.json({ error: "Game not found" }, 404);
// return c.json({ data: game }, 200);
// },
// )
// .post(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Add a game to the user's library using its Steam ID",
// description: "Adds a game to the currently authenticated user's library. Once added, the user can play the game and share their progress with others",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok"))
// },
// },
// description: "Game successfully added to user's library",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No game was found matching the provided Steam ID",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID of the game to be added to the current user's library",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param")
// const game = await Games.fromSteamID(params.steamID)
// if (!game) return c.json({ error: "Game not found" }, 404);
// const res = await Games.linkToCurrentUser(game.id)
// return c.json({ data: res }, 200);
// },
// )
// .delete(
// "/:steamID",
// describeRoute({
// tags: ["Game"],
// summary: "Remove game from user's library",
// description: "Removes a game from the authenticated user's library. The game remains in the system but will no longer be accessible to the user",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Game successfully removed from library",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The game with the specified Steam ID was not found",
// },
// }
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The Steam ID of the game to be removed",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const res = await Games.unLinkFromCurrentUser(params.steamID)
// if (!res) return c.json({ error: "Game not found the library" }, 404);
// return c.json({ data: res }, 200);
// },
// )
// .put(
// "/",
// describeRoute({
// tags: ["Game"],
// summary: "Update game metadata",
// description: "Updates the metadata about a specific game using its Steam ID",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Game successfully updated",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The game with the specified Steam ID was not found",
// },
// }
// }),
// validator(
// "json",
// Games.Info.omit({ id: true }).openapi({
// description: "Game information",
// //@ts-expect-error
// example: { ...Examples.Game, id: undefined }
// })
// ),
// async (c) => {
// const params = c.req.valid("json");
// const res = await Games.create(params)
// if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
// return c.json({ data: res }, 200);
// },
// )
// .get(
// "/:steamID/sessions",
// describeRoute({
// tags: ["Game"],
// summary: "Retrieve game sessions by the associated game's Steam ID",
// description: "Fetches active and public game sessions associated with a specific game using its Steam ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "This game does not have nay publicly active sessions",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Sessions.Info.array().openapi({
// description: "Publicly active sessions associated with the game",
// example: [Examples.Session],
// }),
// ),
// },
// },
// description: "Successfully retrieved game sessions associated with this game",
// },
// },
// }),
// validator(
// "param",
// z.object({
// steamID: Games.Info.shape.steamID.openapi({
// description: "The unique Steam ID used to identify a game",
// example: Examples.Game.steamID,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const sessions = await Sessions.fromSteamID(params.steamID);
// if (!sessions) return c.json({ error: "This game does not have any publicly active game sessions" }, 404);
// return c.json({ data: sessions }, 200);
// },
// );
// }

View File

@@ -2,12 +2,13 @@ import "zod-openapi/extend";
import { Resource } from "sst";
import { ZodError } from "zod";
import { UserApi } from "./user";
import { GameApi } from "./game";
import { TeamApi } from "./team";
import { TaskApi } from "./task";
// import { GameApi } from "./game";
// import { TeamApi } from "./team";
import { logger } from "hono/logger";
import { subjects } from "../subjects";
import { SessionApi } from "./session";
import { MachineApi } from "./machine";
// import { MachineApi } from "./machine";
import { openAPISpecs } from "hono-openapi";
import { SubscriptionApi } from "./subscription";
import { VisibleError } from "@nestri/core/error";
@@ -58,8 +59,8 @@ const auth: MiddlewareHandler = async (c, next) => {
{
type: "device",
properties: {
fingerprint: result.subject.properties.fingerprint,
id: result.subject.properties.id,
hostname: result.subject.properties.hostname,
teamSlug: result.subject.properties.teamSlug,
auth: {
type: "oauth",
clientID: result.aud,
@@ -81,14 +82,16 @@ app
c.header("Cache-Control", "no-store");
return next();
})
.use(auth);
.use(auth)
const routes = app
.get("/", (c) => c.text("Hello there 👋🏾"))
.route("/users", UserApi.route)
.route("/teams", TeamApi.route)
.route("/games", GameApi.route)
.route("/tasks", TaskApi.route)
// .route("/teams", TeamApi.route)
// .route("/games", GameApi.route)
.route("/sessions", SessionApi.route)
.route("/machines", MachineApi.route)
// .route("/machines", MachineApi.route)
.route("/subscriptions", SubscriptionApi.route)
.onError((error, c) => {
console.warn(error);

View File

@@ -1,176 +1,176 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Machines } from "@nestri/core/machine/index";
export module MachineApi {
export const route = new Hono()
.get(
"/",
//FIXME: Add a way to filter through query params
describeRoute({
tags: ["Machine"],
summary: "Retrieve all machines",
description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Machines.Info.array().openapi({
description: "A list of machines associated with the user",
example: [Examples.Machine],
}),
),
},
},
description: "Successfully retrieved the list of machines",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No machines found for the authenticated user",
},
},
}),
async (c) => {
const machines = await Machines.list();
if (!machines) return c.json({ error: "No machines found for this user" }, 404);
return c.json({ data: machines }, 200);
},
)
.get(
"/:fingerprint",
describeRoute({
tags: ["Machine"],
summary: "Retrieve machine by fingerprint",
description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
responses: {
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No machine found matching the provided fingerprint",
},
200: {
content: {
"application/json": {
schema: Result(
Machines.Info.openapi({
description: "Detailed information about the requested machine",
example: Examples.Machine,
}),
),
},
},
description: "Successfully retrieved machine information",
},
},
}),
validator(
"param",
z.object({
fingerprint: Machines.Info.shape.fingerprint.openapi({
description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
example: Examples.Machine.fingerprint,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const machine = await Machines.fromFingerprint(params.fingerprint);
if (!machine) return c.json({ error: "Machine not found" }, 404);
return c.json({ data: machine }, 200);
},
)
.post(
"/:fingerprint",
describeRoute({
tags: ["Machine"],
summary: "Register a machine to an owner",
description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "Machine successfully registered to user's account",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No machine found matching the provided fingerprint",
},
},
}),
validator(
"param",
z.object({
fingerprint: Machines.Info.shape.fingerprint.openapi({
description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
example: Examples.Machine.fingerprint,
}),
}),
),
async (c) => {
const params = c.req.valid("param")
const machine = await Machines.fromFingerprint(params.fingerprint)
if (!machine) return c.json({ error: "Machine not found" }, 404);
const res = await Machines.linkToCurrentUser(machine.id)
return c.json({ data: res }, 200);
},
)
.delete(
"/:fingerprint",
describeRoute({
tags: ["Machine"],
summary: "Unregister machine from user",
description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok")),
},
},
description: "Machine successfully unregistered from user's account",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The machine with the specified fingerprint was not found",
},
}
}),
validator(
"param",
z.object({
fingerprint: Machines.Info.shape.fingerprint.openapi({
description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
example: Examples.Machine.fingerprint,
}),
}),
),
async (c) => {
const params = c.req.valid("param");
const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
if (!res) return c.json({ error: "Machine not found for this user" }, 404);
return c.json({ data: res }, 200);
},
);
}
// import { z } from "zod";
// import { Hono } from "hono";
// import { Result } from "../common";
// import { describeRoute } from "hono-openapi";
// import { Examples } from "@nestri/core/examples";
// import { validator, resolver } from "hono-openapi/zod";
// import { Machines } from "@nestri/core/machine/index";
// export module MachineApi {
// export const route = new Hono()
// .get(
// "/",
// //FIXME: Add a way to filter through query params
// describeRoute({
// tags: ["Machine"],
// summary: "Retrieve all machines",
// description: "Returns a list of all machines registered to the authenticated user in the Nestri network",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(
// // Machines.Info.array().openapi({
// description: "A list of machines associated with the user",
// example: [Examples.Machine],
// }),
// ),
// },
// },
// description: "Successfully retrieved the list of machines",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machines found for the authenticated user",
// },
// },
// }),
// async (c) => {
// const machines = await Machines.list();
// if (!machines) return c.json({ error: "No machines found for this user" }, 404);
// return c.json({ data: machines }, 200);
// },
// )
// .get(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Retrieve machine by fingerprint",
// description: "Fetches detailed information about a specific machine using its unique fingerprint derived from the Linux machine ID",
// responses: {
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machine found matching the provided fingerprint",
// },
// 200: {
// content: {
// "application/json": {
// schema: Result(
// Machines.Info.openapi({
// description: "Detailed information about the requested machine",
// example: Examples.Machine,
// }),
// ),
// },
// },
// description: "Successfully retrieved machine information",
// },
// },
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint used to identify the machine, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const machine = await Machines.fromFingerprint(params.fingerprint);
// if (!machine) return c.json({ error: "Machine not found" }, 404);
// return c.json({ data: machine }, 200);
// },
// )
// .post(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Register a machine to an owner",
// description: "Associates a machine with the currently authenticated user's account, enabling them to manage and control the machine",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok"))
// },
// },
// description: "Machine successfully registered to user's account",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "No machine found matching the provided fingerprint",
// },
// },
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint of the machine to be registered, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param")
// const machine = await Machines.fromFingerprint(params.fingerprint)
// if (!machine) return c.json({ error: "Machine not found" }, 404);
// const res = await Machines.linkToCurrentUser(machine.id)
// return c.json({ data: res }, 200);
// },
// )
// .delete(
// "/:fingerprint",
// describeRoute({
// tags: ["Machine"],
// summary: "Unregister machine from user",
// description: "Removes the association between a machine and the authenticated user's account. This does not delete the machine itself, but removes the user's ability to manage it",
// responses: {
// 200: {
// content: {
// "application/json": {
// schema: Result(z.literal("ok")),
// },
// },
// description: "Machine successfully unregistered from user's account",
// },
// 404: {
// content: {
// "application/json": {
// schema: resolver(z.object({ error: z.string() })),
// },
// },
// description: "The machine with the specified fingerprint was not found",
// },
// }
// }),
// validator(
// "param",
// z.object({
// fingerprint: Machines.Info.shape.fingerprint.openapi({
// description: "The unique fingerprint of the machine to be unregistered, derived from its Linux machine ID",
// example: Examples.Machine.fingerprint,
// }),
// }),
// ),
// async (c) => {
// const params = c.req.valid("param");
// const res = await Machines.unLinkFromCurrentUser(params.fingerprint)
// if (!res) return c.json({ error: "Machine not found for this user" }, 404);
// return c.json({ data: res }, 200);
// },
// );
// }

View File

@@ -2,51 +2,12 @@ import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Games } from "@nestri/core/game/index";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Sessions } from "@nestri/core/session/index";
import { Machines } from "@nestri/core/machine/index";
export module SessionApi {
export const route = new Hono()
.get(
"/",
//FIXME: Add a way to filter through query params
describeRoute({
tags: ["Session"],
summary: "Retrieve all gaming sessions",
description: "Returns a list of all gaming sessions associated with the authenticated user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.array().openapi({
description: "A list of gaming sessions associated with the user",
example: [{ ...Examples.Session, public: false }],
}),
),
},
},
description: "Successfully retrieved the list of gaming sessions",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No gaming sessions found for the authenticated user",
},
},
}),
async (c) => {
const res = await Sessions.list();
if (!res) return c.json({ error: "No gaming sessions found for this user" }, 404);
return c.json({ data: res }, 200);
},
)
.get(
"/active",
describeRoute({
@@ -60,7 +21,7 @@ export module SessionApi {
schema: Result(
Sessions.Info.array().openapi({
description: "A list of active gaming sessions associated with the user",
example: [{ ...Examples.Session, public: false, endedAt: undefined }],
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
}),
),
},
@@ -83,42 +44,6 @@ export module SessionApi {
return c.json({ data: res }, 200);
},
)
.get(
"/active/public",
describeRoute({
tags: ["Session"],
summary: "Retrieve all publicly active gaming sessions",
description: "Returns a list of all publicly active gaming sessions associated",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.array().openapi({
description: "A list of publicly active gaming sessions",
example: [{ ...Examples.Session, public: true, endedAt: undefined }],
}),
),
},
},
description: "Successfully retrieved the list of all publicly active gaming sessions",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No publicly active gaming sessions found",
},
},
}),
async (c) => {
const res = await Sessions.getPublicActive();
if (!res) return c.json({ error: "No publicly active gaming sessions found" }, 404);
return c.json({ data: res }, 200);
},
)
.get(
"/:id",
describeRoute({
@@ -197,26 +122,13 @@ export module SessionApi {
description: "Whether the session is publicly viewable by all users. If false, only authorized users can access it",
example: Examples.Session.public
}),
steamID: Games.Info.shape.steamID.openapi({
description: "The Steam ID of the game the user wants to play",
example: Examples.Game.steamID
}),
fingerprint: Machines.Info.shape.fingerprint.openapi({
description: "The unique fingerprint of the machine to play on, derived from its Linux machine ID",
example: Examples.Machine.fingerprint
}),
name: Sessions.Info.shape.name.openapi({
description: "The human readable name to give this session",
example: Examples.Session.name
})
}),
),
async (c) => {
const params = c.req.valid("json")
//FIXME:
const session = await Sessions.create(params)
if (session.error) return c.json({ error: session.error }, 422);
return c.json({ data: session.data }, 200);
if (!session) return c.json({ error: "Something went wrong while creating a session" }, 422);
return c.json({ data: session }, 200);
},
)
.delete(
@@ -240,7 +152,7 @@ export module SessionApi {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The session with the specified ID could not be found",
description: "The session with the specified ID could not be found by this user",
},
}
}),
@@ -256,7 +168,7 @@ export module SessionApi {
async (c) => {
const params = c.req.valid("param");
const res = await Sessions.end(params.id)
if (!res) return c.json({ error: "Session not found for this user" }, 404);
if (!res) return c.json({ error: "Session is not owned by this user" }, 404);
return c.json({ data: res }, 200);
},
);

View File

@@ -5,8 +5,6 @@ import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { Subscriptions } from "@nestri/core/subscription/index";
import { Email } from "@nestri/core/email/index";
export module SubscriptionApi {
export const route = new Hono()
.get(
@@ -40,7 +38,7 @@ export module SubscriptionApi {
},
}),
async (c) => {
const data = await Subscriptions.list();
const data = await Subscriptions.list(undefined);
if (!data) return c.json({ error: "No subscriptions found for this user" }, 404);
return c.json({ data }, 200);
},

View File

@@ -0,0 +1,277 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common";
import { describeRoute } from "hono-openapi";
import { Tasks } from "@nestri/core/task/index";
import { Examples } from "@nestri/core/examples";
import { validator, resolver } from "hono-openapi/zod";
import { useCurrentUser } from "@nestri/core/actor";
import { Subscriptions } from "@nestri/core/subscription/index";
import { Sessions } from "@nestri/core/session/index";
export module TaskApi {
export const route = new Hono()
.get("/",
describeRoute({
tags: ["Task"],
summary: "List Tasks",
description: "List all tasks by this user",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Tasks.Info.openapi({
description: "A task example gotten from this task id",
examples: [Examples.Task],
}))
},
},
description: "Tasks owned by this user were found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No tasks for this user were not found.",
},
},
}),
async (c) => {
const task = await Tasks.list();
if (!task) return c.json({ error: "No tasks were found for this user" }, 404);
return c.json({ data: task }, 200);
},
)
.get("/:id",
describeRoute({
tags: ["Task"],
summary: "Get Task",
description: "Get a task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Tasks.Info.openapi({
description: "A task example gotten from this task id",
example: Examples.Task,
}))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "ID of the task to get",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
return c.json({ data: task }, 200);
},
)
.get("/:id/session",
describeRoute({
tags: ["Task"],
summary: "Get the current session running on this task",
description: "Get a task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.openapi({
description: "A session running on this task",
example: Examples.Session,
}))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "ID of the task to get session information about",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
const session = await Sessions.fromTaskID(task.id)
if (!session) return c.json({ error: "No session was found running on this task" }, 404);
return c.json({ data: session }, 200);
},
)
.delete("/:id",
describeRoute({
tags: ["Task"],
summary: "Stop Task",
description: "Stop a running task by its id",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.literal("ok"))
},
},
description: "A task with this id was found",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id was not found.",
},
},
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "The id of the task to get",
example: Examples.Task.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const task = await Tasks.fromID(param.id);
if (!task) return c.json({ error: "Task was not found" }, 404);
//End any running tasks then (and only then) kill the task
const session = await Sessions.fromTaskID(task.id)
if (session) { await Sessions.end(session.id) }
const res = await Tasks.stop({ taskID: task.taskID, id: param.id })
if (!res) return c.json({ error: "Something went wrong trying to stop the task" }, 404);
return c.json({ data: "ok" }, 200);
},
)
.post("/",
describeRoute({
tags: ["Task"],
summary: "Create Task",
description: "Create a task",
responses: {
200: {
content: {
"application/json": {
schema: Result(Tasks.Info.shape.id.openapi({
description: "The id of the task created",
example: Examples.Task.id,
}))
},
},
description: "A task with this id was created",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "A task with this id could not be created",
},
401: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "You are not authorised to do this",
},
},
}),
async (c) => {
const user = useCurrentUser();
// const data = await Subscriptions.list(undefined);
// if (!data) return c.json({ error: "You need a subscription to create a task" }, 404);
if (user) {
const task = await Tasks.create();
if (!task) return c.json({ error: "Task could not be created" }, 404);
return c.json({ data: task }, 200);
}
return c.json({ error: "You are not authorized to do this" }, 401);
},
)
.put(
"/:id",
describeRoute({
tags: ["Task"],
summary: "Get an update on a task",
description: "Updates the metadata about a task by querying remote task",
responses: {
200: {
content: {
"application/json": {
schema: Result(Tasks.Info.openapi({
description: "The updated information about this task",
example: Examples.Task
})),
},
},
description: "Task successfully updated",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "The task specified id was not found",
},
}
}),
validator(
"param",
z.object({
id: Tasks.Info.shape.id.openapi({
description: "The id of the task to update on",
example: Examples.Task.id
})
})
),
async (c) => {
const params = c.req.valid("param");
const res = await Tasks.update(params.id)
if (!res) return c.json({ error: "Something went seriously wrong" }, 404);
return c.json({ data: res[0] }, 200);
},
)
}

View File

@@ -184,7 +184,7 @@ export module TeamApi {
const params = c.req.valid("param");
const team = await Teams.fromSlug(params.slug)
if (!team) return c.json({ error: "Team not found" }, 404);
if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
// if (!team.owner) return c.json({ error: "Your are not authorised to delete this team" }, 401)
const res = await Teams.remove(team.id);
return c.json({ data: res }, 200);
},
@@ -231,7 +231,7 @@ export module TeamApi {
const params = c.req.valid("param");
const team = await Teams.fromSlug(params.slug)
if (!team) return c.json({ error: "Team not found" }, 404);
if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
// if (!team.owner) return c.json({ error: "Your are not authorized to delete this team" }, 401)
return c.json({ data: "ok" }, 200);
},
)

View File

@@ -5,6 +5,7 @@ import { describeRoute } from "hono-openapi";
import { Examples } from "@nestri/core/examples";
import { Profiles } from "@nestri/core/profile/index";
import { validator, resolver } from "hono-openapi/zod";
import { Sessions } from "@nestri/core/session/index";
export module UserApi {
export const route = new Hono()
@@ -12,7 +13,7 @@ export module UserApi {
"/@me",
describeRoute({
tags: ["User"],
summary: "Retrieve current user profile",
summary: "Retrieve current user's profile",
description: "Returns the current authenticate user's profile",
responses: {
200: {
@@ -43,4 +44,134 @@ export module UserApi {
return c.json({ data: profile }, 200);
},
)
.get(
"/",
describeRoute({
tags: ["User"],
summary: "List all user profiles",
description: "Returns all user profiles",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Profiles.Info.openapi({
description: "The profiles of all users",
examples: [Examples.Profile],
}),
),
},
},
description: "Successfully retrieved all user profiles",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No user profiles were found",
},
},
}), async (c) => {
const profiles = await Profiles.list();
if (!profiles) return c.json({ error: "No user profiles were found" }, 404);
return c.json({ data: profiles }, 200);
},
)
.get(
"/:id",
describeRoute({
tags: ["User"],
summary: "Retrieve a user's profile",
description: "Gets a user's profile by their id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Profiles.Info.openapi({
description: "The profile of the users",
example: Examples.Profile,
}),
),
},
},
description: "Successfully retrieved the user profile",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No user profile was found",
},
},
}),
validator(
"param",
z.object({
id: Profiles.Info.shape.id.openapi({
description: "ID of the user profile to get",
example: Examples.Profile.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
console.log("id", param.id)
const profiles = await Profiles.fromID(param.id);
if (!profiles) return c.json({ error: "No user profile was found" }, 404);
return c.json({ data: profiles }, 200);
},
)
.get(
"/:id/session",
describeRoute({
tags: ["User"],
summary: "Retrieve a user's active session",
description: "Get a user's active gaming session details by their id",
responses: {
200: {
content: {
"application/json": {
schema: Result(
Sessions.Info.openapi({
description: "The active session of this user",
example: Examples.Session,
}),
),
},
},
description: "Successfully retrieved the active user gaming session",
},
404: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string() })),
},
},
description: "No active gaming session for this user",
},
},
}),
validator(
"param",
z.object({
id: Sessions.Info.shape.id.openapi({
description: "ID of the user's gaming session to get",
example: Examples.Session.id,
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const ownerID = await Profiles.fromIDToOwner(param.id);
if (!ownerID) return c.json({ error: "We could not get the owner of this profile" }, 404);
const session = await Sessions.fromOwnerID(ownerID)
if(!session) return c.json({ error: "This user profile does not have active sessions" }, 404);
return c.json({ data: session }, 200);
},
)
}

View File

@@ -8,16 +8,19 @@ import { subjects } from "./subjects"
import { PasswordUI } from "./ui/password"
import { Email } from "@nestri/core/email/index"
import { Users } from "@nestri/core/user/index"
import { Teams } from "@nestri/core/team/index"
import { authorizer } from "@openauthjs/openauth"
import { Profiles } from "@nestri/core/profile/index"
import { handleDiscord, handleGithub } from "./utils";
import { type CFRequest } from "@nestri/core/types"
import { GithubAdapter } from "./ui/adapters/github";
import { DiscordAdapter } from "./ui/adapters/discord";
import { Machines } from "@nestri/core/machine/index"
import { Instances } from "@nestri/core/instance/index"
import { PasswordAdapter } from "./ui/adapters/password"
import { type Adapter } from "@openauthjs/openauth/adapter/adapter"
import { CloudflareStorage } from "@openauthjs/openauth/storage/cloudflare"
import { Subscriptions } from "@nestri/core/subscription/index";
import type { Subscription } from "./type";
interface Env {
CloudflareAuthKV: KVNamespace
}
@@ -57,8 +60,8 @@ export default {
title: "Nestri | Auth",
primary: "#FF4F01",
//TODO: Change this in prod
logo: "https://nestri.pages.dev/logo.webp",
favicon: "https://nestri.pages.dev/seo/favicon.ico",
logo: "https://nestri.io/logo.webp",
favicon: "https://nestri.io/seo/favicon.ico",
background: {
light: "#f5f5f5 ",
dark: "#171717"
@@ -100,23 +103,23 @@ export default {
if (input.clientSecret !== Resource.AuthFingerprintKey.value) {
throw new Error("Invalid authorization token");
}
const fingerprint = input.params.fingerprint;
if (!fingerprint) {
throw new Error("Fingerprint is required");
const teamSlug = input.params.team;
if (!teamSlug) {
throw new Error("Team slug is required");
}
const hostname = input.params.hostname;
if (!hostname) {
throw new Error("Hostname is required");
}
return {
fingerprint,
hostname
hostname,
teamSlug
};
},
init() { }
} as Adapter<{ fingerprint: string; hostname: string }>,
} as Adapter<{ teamSlug: string; hostname: string; }>,
},
allow: async (input) => {
const url = new URL(input.redirectURI);
@@ -127,24 +130,17 @@ export default {
},
success: async (ctx, value) => {
if (value.provider === "device") {
let exists = await Machines.fromFingerprint(value.fingerprint);
if (!exists) {
const machineID = await Machines.create({
fingerprint: value.fingerprint,
hostname: value.hostname,
});
const team = await Teams.fromSlug(value.teamSlug)
console.log("team", team)
console.log("teamSlug", value.teamSlug)
if (team) {
await Instances.create({ hostname: value.hostname, teamID: team.id })
return await ctx.subject("device", {
id: machineID,
fingerprint: value.fingerprint
teamSlug: value.teamSlug,
hostname: value.hostname,
})
}
return await ctx.subject("device", {
id: exists.id,
fingerprint: value.fingerprint
})
}
if (value.provider === "password") {
@@ -152,14 +148,14 @@ export default {
const username = value.username
const token = await Users.create(email)
const usr = await Users.fromEmail(email);
const exists = await Profiles.getProfile(usr.id)
if(username && !exists){
const exists = await Profiles.fromOwnerID(usr.id)
if (username && !exists) {
await Profiles.create({ owner: usr.id, username })
}
return await ctx.subject("user", {
accessToken: token,
userID: usr.id
userID: usr.id,
});
}
@@ -180,15 +176,15 @@ export default {
try {
const token = await Users.create(user.primary.email)
const usr = await Users.fromEmail(user.primary.email);
const exists = await Profiles.getProfile(usr.id)
console.log("exists",exists)
const exists = await Profiles.fromOwnerID(usr.id)
console.log("exists", exists)
if (!exists) {
await Profiles.create({ owner: usr.id, avatarUrl: user.avatar, username: user.username })
}
return await ctx.subject("user", {
accessToken: token,
userID: usr.id
userID: usr.id,
});
} catch (error) {

View File

@@ -0,0 +1,38 @@
import { Resource } from "sst";
import { subjects } from "../subjects";
import { realtime } from "sst/aws/realtime";
import { createClient } from "@openauthjs/openauth/client";
export const handler = realtime.authorizer(async (token) => {
//TODO: Use the following criteria for a topic - team-slug/container-id (container ids are not unique globally)
//TODO: Allow the authorizer to subscriber/publisher to listen on - team-slug topics only (as the container will listen on the team-slug/container-id topic to be specific)
// Return the topics to subscribe and publish
const client = createClient({
clientID: "api",
issuer: Resource.Urls.auth
});
const result = await client.verify(subjects, token);
if (result.err) {
console.log("error", result.err)
return {
subscribe: [],
publish: [],
};
}
if (result.subject.type != "device") {
return {
subscribe: [],
publish: [],
};
}
return {
//It can publish and listen to other instances under this team
subscribe: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
publish: [`${Resource.App.name}/${Resource.App.stage}/${result.subject.properties.teamSlug}/*`],
};
});

View File

@@ -0,0 +1,64 @@
import { ECSClient, RunTaskCommand } from "@aws-sdk/client-ecs";
const client = new ECSClient()
export const handler = async (event: any) => {
console.log("event", event)
const clusterArn = process.env.ECS_CLUSTER
const taskDefinitionArn = process.env.TASK_DEFINITION
const authFingerprintKey = process.env.AUTH_FINGERPRINT
try {
const runResponse = await client.send(new RunTaskCommand({
taskDefinition: taskDefinitionArn,
cluster: clusterArn,
count: 1,
launchType: "EC2",
overrides: {
containerOverrides: [
{
name: "nestri",
environment: [
{
name: "AUTH_FINGERPRINT_KEY",
value: authFingerprintKey
},
{
name: "NESTRI_ROOM",
value: "testing-right-now"
}
]
}
]
}
}))
// Check if tasks were started
if (!runResponse.tasks || runResponse.tasks.length === 0) {
throw new Error("No tasks were started");
}
// Extract task details
const task = runResponse.tasks[0];
const taskArn = task.taskArn!;
const taskId = taskArn.split('/').pop()!; // Extract task ID from ARN
const taskStatus = task.lastStatus!;
return {
statusCode: 200,
body: JSON.stringify({
status: "sent",
taskId: taskId,
taskStatus: taskStatus,
taskArn: taskArn
}, null, 2),
};
} catch (err) {
console.error("Error starting task:", err);
return {
statusCode: 500,
body: JSON.stringify({ error: "Failed to start task" }, null, 2),
};
}
};

View File

@@ -1,65 +0,0 @@
import "zod-openapi/extend";
import { Hono } from "hono";
import { logger } from "hono/logger";
import type { HonoBindings } from "./types";
import { ApiSession } from "./session";
import { openAPISpecs } from "hono-openapi";
const app = new Hono<{ Bindings: HonoBindings }>().basePath('/parties/main/:room');
app
.use(logger(), async (c, next) => {
c.header("Cache-Control", "no-store");
try {
await next();
} catch (e: any) {
return c.json(
{
error: {
message: e.message || "Internal Server Error",
status: e.status || 500,
},
},
e.status || 500
);
}
})
const routes = app
.get("/health", (c) => {
return c.json({
status: "healthy",
timestamp: new Date().toISOString(),
});
})
.route("/session", ApiSession.route)
app.get(
"/doc",
openAPISpecs(routes, {
documentation: {
info: {
title: "Nestri Realtime API",
description:
"The Nestri realtime API gives you the power to connect to your remote machine and relays from a single station",
version: "0.3.0",
},
components: {
securitySchemes: {
Bearer: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
security: [{ Bearer: [] }],
servers: [
{ description: "Production", url: "https://api.nestri.io" },
],
},
}),
);
export type Routes = typeof routes;
export default app

View File

@@ -1,63 +0,0 @@
import app from "./hono"
import type * as Party from "partykit/server";
import { tryAuthentication } from "./utils";
export default class Server implements Party.Server {
constructor(readonly room: Party.Room) { }
static async onBeforeRequest(req: Party.Request, lobby: Party.Lobby) {
const docs = new URL(req.url).toString().endsWith("/doc")
if (docs) {
return req
}
try {
return await tryAuthentication(req, lobby)
} catch (e: any) {
// authentication failed!
return new Response(e, { status: 401 });
}
}
static async onBeforeConnect(request: Party.Request, lobby: Party.Lobby) {
try {
return await tryAuthentication(request, lobby)
} catch (e: any) {
// authentication failed!
return new Response(e, { status: 401 });
}
}
onRequest(req: Party.Request): Response | Promise<Response> {
return app.fetch(req as any, { room: this.room })
}
getConnectionTags(conn: Party.Connection, ctx: Party.ConnectionContext) {
return [conn.id, ctx.request.cf?.country as any]
}
onConnect(conn: Party.Connection, ctx: Party.ConnectionContext): void | Promise<void> {
console.log(`Connected:, id:${conn.id}, room: ${this.room.id}, url: ${new URL(ctx.request.url).pathname}`);
this.getConnectionTags(conn, ctx)
}
onMessage(message: string, sender: Party.Connection) {
// let's log the message
console.log(`connection ${sender.id} sent message: ${message}`);
// console.log("tags", this.room.getConnections())
// for (const british of this.room.getConnections(sender.id)) {
// british.send(`Pip-pip!`);
// }
// // as well as broadcast it to all the other connections in the room...
// this.room.broadcast(
// `${sender.id}: ${message}`,
// // ...except for the connection it came from
// [sender.id]
// );
}
}
Server satisfies Party.Worker;

View File

@@ -1,217 +0,0 @@
import { z } from "zod";
import { Hono } from "hono";
import { Result } from "../common"
import { describeRoute } from "hono-openapi";
import type { HonoBindings, WSMessage } from "./types";
import { validator, resolver } from "hono-openapi/zod";
export module ApiSession {
export const route = new Hono<{ Bindings: HonoBindings }>()
.post("/:sessionID/start",
describeRoute({
tags: ["Session"],
summary: "Start a session",
description: "Start a session on this machine",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.object({
success: z.boolean(),
message: z.string(),
sessionID: z.string()
}))
},
},
description: "Session started successfully",
},
500: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string(), details: z.string() })),
},
},
description: "There was a problem trying to start your session",
},
},
}),
validator(
"param",
z.object({
sessionID: z.string().openapi({
description: "The session ID to start",
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const room = c.env.room
const message: WSMessage = {
type: "START_GAME",
sessionID: param.sessionID,
};
try {
room.broadcast(JSON.stringify(message));
return c.json({
success: true,
message: "Game start signal sent",
"sessionID": param.sessionID,
});
} catch (error: any) {
return c.json(
{
error: {
message: "Failed to start game session",
details: error.message,
},
},
500
);
}
}
)
.post("/:sessionID/end",
describeRoute({
tags: ["Session"],
summary: "End a session",
description: "End a session on this machine",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.object({
success: z.boolean(),
message: z.string(),
sessionID: z.string()
}))
},
},
description: "Session successfully ended",
},
500: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string(), details: z.string() })),
},
},
description: "There was a problem trying to end your session",
},
},
}),
validator(
"param",
z.object({
sessionID: z.string().openapi({
description: "The session ID to end",
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const room = c.env.room
const message: WSMessage = {
type: "END_GAME",
sessionID: param.sessionID,
};
try {
room.broadcast(JSON.stringify(message));
return c.json({
success: true,
message: "Game end signal sent",
"sessionID": param.sessionID,
});
} catch (error: any) {
return c.json(
{
error: {
message: "Failed to end game session",
details: error.message,
},
},
500
);
}
}
)
.post("/:sessionID/status",
describeRoute({
tags: ["Session"],
summary: "Get the status of a session",
description: "Get the status of a session on this machine",
responses: {
200: {
content: {
"application/json": {
schema: Result(z.object({
success: z.boolean(),
message: z.string(),
sessionID: z.string()
}))
},
},
description: "Session status query was successful"
},
500: {
content: {
"application/json": {
schema: resolver(z.object({ error: z.string(), details: z.string() })),
},
},
description: "There was a problem trying to querying the status of your session",
},
},
}),
validator(
"param",
z.object({
sessionID: z.string().openapi({
description: "The session ID to query",
example: "18d8b4b5-29ba-4a62-8cf9-7059449907a7",
}),
}),
),
async (c) => {
const param = c.req.valid("param");
const room = c.env.room
const message: WSMessage = {
type: "END_GAME",
sessionID: param.sessionID,
};
try {
room.broadcast(JSON.stringify(message));
return c.json({
success: true,
message: "Game end signal sent",
"sessionID": param.sessionID,
});
} catch (error: any) {
return c.json(
{
error: {
message: "Failed to end game session",
details: error.message,
},
},
500
);
}
}
)
}

View File

@@ -0,0 +1,4 @@
export const handler = async (event: any) => {
console.log(event);
return "ok";
};

View File

@@ -1,11 +0,0 @@
import type * as Party from "partykit/server";
export interface HonoBindings {
room: Party.Room;
}
export type WSMessage = {
type: "START_GAME" | "END_GAME" | "GAME_STATUS";
sessionID: string;
payload?: any;
};

View File

@@ -1,21 +0,0 @@
import type * as Party from "partykit/server";
export async function tryAuthentication(req: Party.Request, lobby: Party.Lobby) {
const authHeader = req.headers.get("authorization") ?? new URL(req.url).searchParams.get("authorization")
if (authHeader) {
const match = authHeader.match(/^Bearer (.+)$/);
if (!match || !match[1]) {
throw new Error("Bearer token not found or improperly formatted");
}
const bearerToken = match[1];
if (bearerToken !== lobby.env.AUTH_FINGERPRINT) {
throw new Error("Invalid authorization token");
}
return req// app.fetch(req as any, { room: this.room })
}
throw new Error("You are not authorized to be here")
}

View File

@@ -1,13 +1,14 @@
import * as v from "valibot"
import { Subscription } from "./type"
import { createSubjects } from "@openauthjs/openauth"
export const subjects = createSubjects({
user: v.object({
accessToken: v.string(),
userID: v.string(),
userID: v.string()
}),
device: v.object({
fingerprint: v.string(),
id: v.string()
teamSlug: v.string(),
hostname: v.string(),
})
})

View File

@@ -0,0 +1,4 @@
export enum Subscription {
Pro = "Pro",
Free = "Free"
}

View File

@@ -184,8 +184,8 @@ export function PasswordAdapter(config: PasswordConfig) {
"password",
])
if (existing) return transition(adapter, { type: "email_taken" })
const existingUsername = await Profiles.fromUsername(username)
if (existingUsername) return transition(adapter, { type: "username_taken" })
// const existingUsername = await Profiles.fromUsername(username)
// if (existingUsername) return transition(adapter, { type: "username_taken" })
const code = generate()
await config.sendCode(email, code)
return transition({

View File

@@ -16,7 +16,7 @@ const DEFAULT_COPY = {
error_invalid_code: "Code is incorrect.",
error_invalid_email: "Email is not valid.",
error_invalid_password: "Password is incorrect.",
error_invalid_username: "Username must only contain numbers and small letters.",
error_invalid_username: "Username can only contain letters.",
error_password_mismatch: "Passwords do not match.",
register_title: "Welcome to the app",
register_description: "Sign in with your email",

View File

@@ -2,8 +2,7 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
import "sst"
export {}
import "sst"
declare module "sst" {
export interface Resource {
@@ -11,6 +10,14 @@ declare module "sst" {
"type": "random.index/randomString.RandomString"
"value": string
}
"AwsAccessKey": {
"type": "sst.sst.Secret"
"value": string
}
"AwsSecretKey": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
@@ -39,6 +46,14 @@ declare module "sst" {
"type": "sst.sst.Secret"
"value": string
}
"NestriGPUCluster": {
"type": "aws.ecs/cluster.Cluster"
"value": string
}
"NestriGPUTask": {
"type": "aws.ecs/taskDefinition.TaskDefinition"
"value": string
}
"Urls": {
"api": string
"auth": string
@@ -55,3 +70,6 @@ declare module "sst" {
"CloudflareAuthKV": cloudflare.KVNamespace
}
}
import "sst"
export {}

View File

@@ -8,27 +8,41 @@ import {
AnswerType,
} from "./messages";
//FIXME: Sometimes the room will wait to say offline, then appear to be online after retrying :D
// This works for me, with my trashy internet, does it work for you as well?
export class WebRTCStream {
private _ws: WebSocket | undefined = undefined;
private _pc: RTCPeerConnection | undefined = undefined;
private _mediaStream: MediaStream | undefined = undefined;
private _dataChannel: RTCDataChannel | undefined = undefined;
private _onConnected: ((stream: MediaStream | null) => void) | undefined = undefined;
private _connectionTimeout: number = 7000;
private _connectionTimer: NodeJS.Timeout | NodeJS.Timer | undefined = undefined;
private _serverURL: string | undefined = undefined;
private _roomName: string | undefined = undefined;
private _isConnected: boolean = false; // Add flag to track connection state
constructor(serverURL: string, roomName: string, connectedCallback: (stream: MediaStream | null) => void) {
// If roomName is not provided, return
if (roomName.length <= 0) {
console.error("Room name not provided");
return;
}
this._onConnected = connectedCallback;
this._serverURL = serverURL;
this._roomName = roomName;
this._setup(serverURL, roomName);
}
private _setup(serverURL: string, roomName: string) {
// Don't setup new connection if already connected
if (this._isConnected) {
console.log("Already connected, skipping setup");
return;
}
console.log("Setting up WebSocket");
// Replace http/https with ws/wss
const wsURL = serverURL.replace(/^http/, "ws");
this._ws = new WebSocket(`${wsURL}/api/ws/${roomName}`);
this._ws.onopen = async () => {
@@ -68,14 +82,21 @@ export class WebRTCStream {
break;
case "ice":
if (!this._pc) break;
// If remote description is not set yet, hold the ICE candidates
if (this._pc.remoteDescription) {
await this._pc.addIceCandidate((message as MessageICE).candidate);
// Add held ICE candidates
for (const ice of iceHolder) {
await this._pc.addIceCandidate(ice);
try {
await this._pc.addIceCandidate((message as MessageICE).candidate);
// Add held ICE candidates
for (const ice of iceHolder) {
try {
await this._pc.addIceCandidate(ice);
} catch (e) {
console.error("Error adding held ICE candidate: ", e);
}
}
iceHolder = [];
} catch (e) {
console.error("Error adding ICE candidate: ", e);
}
iceHolder = [];
} else {
iceHolder.push((message as MessageICE).candidate);
}
@@ -108,14 +129,12 @@ export class WebRTCStream {
this._onConnected(null);
// Clear PeerConnection
if (this._pc) {
this._pc.close();
this._pc = undefined;
}
this._cleanupPeerConnection()
setTimeout(() => {
this._setup(serverURL, roomName);
}, 3000);
this._handleConnectionFailure()
// setTimeout(() => {
// this._setup(serverURL, roomName);
// }, this._connectionTimeout);
}
this._ws.onerror = (e) => {
@@ -123,7 +142,17 @@ export class WebRTCStream {
}
}
// Forces opus to stereo in Chromium browsers, because of course
private forceOpusStereo(SDP: string): string {
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
return SDP.replace(/(minptime=10;useinbandfec=1)/, "$1;stereo=1;sprop-stereo=1;");
}
private _setupPeerConnection() {
if (this._pc) {
this._cleanupPeerConnection();
}
console.log("Setting up PeerConnection");
this._pc = new RTCPeerConnection({
iceServers: [
@@ -133,17 +162,27 @@ export class WebRTCStream {
],
});
// Start connection timeout
this._startConnectionTimer();
this._pc.ontrack = (e) => {
console.log("Track received: ", e.track);
this._mediaStream = e.streams[e.streams.length - 1];
this._checkConnectionState();
};
this._pc.onconnectionstatechange = () => {
console.log("Connection state: ", this._pc!.connectionState);
if (this._pc!.connectionState === "connected") {
if (this._onConnected && this._mediaStream)
this._onConnected(this._mediaStream);
}
console.log("Connection state changed to: ", this._pc!.connectionState);
this._checkConnectionState();
};
this._pc.oniceconnectionstatechange = () => {
console.log("ICE connection state changed to: ", this._pc!.iceConnectionState);
this._checkConnectionState();
};
this._pc.onicegatheringstatechange = () => {
console.log("ICE gathering state changed to: ", this._pc!.iceGatheringState);
};
this._pc.onicecandidate = (e) => {
@@ -154,18 +193,100 @@ export class WebRTCStream {
};
this._ws!.send(JSON.stringify(message));
}
}
};
this._pc.ondatachannel = (e) => {
this._dataChannel = e.channel;
this._setupDataChannelEvents();
};
}
private _checkConnectionState() {
if (!this._pc) return;
console.log("Checking connection state:", {
connectionState: this._pc.connectionState,
iceConnectionState: this._pc.iceConnectionState,
hasMediaStream: !!this._mediaStream,
isConnected: this._isConnected
});
if (this._pc.connectionState === "connected" && this._mediaStream) {
this._clearConnectionTimer();
if (!this._isConnected) { // Only trigger callback if not already connected
this._isConnected = true;
if (this._onConnected) {
this._onConnected(this._mediaStream);
}
}
} else if (this._pc.connectionState === "failed" ||
this._pc.connectionState === "closed" ||
this._pc.iceConnectionState === "failed") {
console.log("Connection failed or closed, attempting reconnect");
this._isConnected = false; // Reset connected state
this._handleConnectionFailure();
}
}
// Forces opus to stereo in Chromium browsers, because of course
private forceOpusStereo(SDP: string): string {
// Look for "minptime=10;useinbandfec=1" and replace with "minptime=10;useinbandfec=1;stereo=1;sprop-stereo=1;"
return SDP.replace(/(minptime=10;useinbandfec=1)/, "$1;stereo=1;sprop-stereo=1;");
private _handleConnectionFailure() {
this._clearConnectionTimer();
if (this._isConnected) { // Only notify if previously connected
this._isConnected = false;
if (this._onConnected) {
this._onConnected(null);
}
}
this._cleanupPeerConnection();
// Attempt to reconnect only if not already connected
if (!this._isConnected && this._serverURL && this._roomName) {
this._setup(this._serverURL, this._roomName);
}
}
private _startConnectionTimer() {
this._clearConnectionTimer();
this._connectionTimer = setTimeout(() => {
console.log("Connection timeout reached");
this._handleConnectionFailure();
}, this._connectionTimeout);
}
private _cleanupPeerConnection() {
if (this._pc) {
try {
this._pc.close();
} catch (err) {
console.error("Error closing peer connection:", err);
}
this._pc = undefined;
}
if (this._mediaStream) {
try {
this._mediaStream.getTracks().forEach(track => track.stop());
} catch (err) {
console.error("Error stopping media tracks:", err);
}
this._mediaStream = undefined;
}
if (this._dataChannel) {
try {
this._dataChannel.close();
} catch (err) {
console.error("Error closing data channel:", err);
}
this._dataChannel = undefined;
}
this._isConnected = false; // Reset connected state during cleanup
}
private _clearConnectionTimer() {
if (this._connectionTimer) {
clearTimeout(this._connectionTimer as any);
this._connectionTimer = undefined;
}
}
private _setupDataChannelEvents() {
@@ -183,4 +304,14 @@ export class WebRTCStream {
else
console.log("Data channel not open or not established.");
}
}
public disconnect() {
this._clearConnectionTimer();
this._cleanupPeerConnection();
if (this._ws) {
this._ws.close();
this._ws = undefined;
}
this._isConnected = false;
}
}

View File

@@ -2,57 +2,8 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientID": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}
export {}

23
packages/maitred/go.mod Normal file
View File

@@ -0,0 +1,23 @@
module nestri/maitred
go 1.23.3
require github.com/eclipse/paho.golang v0.22.0
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/lipgloss v0.10.0 // indirect
github.com/charmbracelet/log v0.4.0 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.18 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/oklog/ulid/v2 v2.1.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
)

49
packages/maitred/go.sum Normal file
View File

@@ -0,0 +1,49 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s=
github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE=
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/eclipse/paho.golang v0.22.0 h1:JhhUngr8TBlyUZDZw/L6WVayPi9qmSmdWeki48i5AVE=
github.com/eclipse/paho.golang v0.22.0/go.mod h1:9ZiYJ93iEfGRJri8tErNeStPKLXIGBHiqbHV74t5pqI=
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.18 h1:DOKFKCQ7FNG2L1rbrmstDN4QVRdS89Nkh85u68Uwp98=
github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU=
github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A=
go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

21
packages/maitred/main.go Normal file
View File

@@ -0,0 +1,21 @@
package main
import (
"nestri/maitred/pkg/party"
"os"
"github.com/charmbracelet/log"
)
func main() {
var teamSlug string //FIXME: Switch to team-slug as they are more memorable but still unique
if len(os.Args) > 1 {
teamSlug = os.Args[1]
} else {
log.Fatal("Nestri needs a team slug to register this container to")
}
party.Run(teamSlug)
//TODO: On stop here, set the API as the instance is not running (stopped)
}

View File

@@ -4,10 +4,13 @@ import (
"encoding/json"
"fmt"
"io"
"nestrilabs/cli/internal/machine"
"nestrilabs/cli/internal/resource"
"nestri/maitred/pkg/resource"
"net/http"
"net/url"
"os"
"os/exec"
"github.com/charmbracelet/log"
)
type UserCredentials struct {
@@ -15,15 +18,17 @@ type UserCredentials struct {
RefreshToken string `json:"refresh_token"`
}
func FetchUserCredentials() (*UserCredentials, error) {
m := machine.NewMachine()
fingerprint := m.GetMachineID()
func FetchUserToken(teamSlug string) (*UserCredentials, error) {
hostname, err := os.Hostname()
if err != nil {
log.Fatal("Could not get the hostname")
}
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", "device")
data.Set("client_secret", resource.Resource.AuthFingerprintKey.Value)
data.Set("hostname", m.Hostname)
data.Set("fingerprint", fingerprint)
data.Set("team", teamSlug)
data.Set("hostname", hostname)
data.Set("provider", "device")
resp, err := http.PostForm(resource.Resource.Auth.Url+"/token", data)
if err != nil {
@@ -42,3 +47,12 @@ func FetchUserCredentials() (*UserCredentials, error) {
}
return &credentials, nil
}
func GetHostname() string {
cmd, err := exec.Command("cat", "/etc/hostname").Output()
if err != nil {
log.Error("error getting container hostname", "err", err)
}
output := string(cmd)
return output
}

View File

@@ -0,0 +1,27 @@
package party
import (
"github.com/charmbracelet/log"
)
// logger implements the paho.Logger interface
type logger struct {
prefix string
}
// Println is the library provided NOOPLogger's
// implementation of the required interface function()
func (l logger) Println(v ...interface{}) {
// fmt.Println(append([]interface{}{l.prefix + ":"}, v...)...)
log.Info(l.prefix, "info", v)
}
// Printf is the library provided NOOPLogger's
// implementation of the required interface function(){}
func (l logger) Printf(format string, v ...interface{}) {
// if len(format) > 0 && format[len(format)-1] != '\n' {
// format = format + "\n" // some log calls in paho do not add \n
// }
// fmt.Printf(l.prefix+":"+format, v...)
log.Info(l.prefix, "info", v)
}

View File

@@ -0,0 +1,129 @@
package party
import (
"context"
"fmt"
"nestri/maitred/pkg/auth"
"nestri/maitred/pkg/resource"
"net/url"
"os"
"os/signal"
"syscall"
"time"
"github.com/charmbracelet/log"
"github.com/eclipse/paho.golang/autopaho"
"github.com/eclipse/paho.golang/paho"
)
func Run(teamSlug string) {
var topic = fmt.Sprintf("%s/%s/%s", resource.Resource.App.Name, resource.Resource.App.Stage, teamSlug)
var serverURL = fmt.Sprintf("wss://%s/mqtt?x-amz-customauthorizer-name=%s", resource.Resource.Party.Endpoint, resource.Resource.Party.Authorizer)
var clientID = generateClientID()
hostname, err := os.Hostname()
if err != nil {
log.Fatal(" Could not get the hostname")
}
// App will run until cancelled by user (e.g. ctrl-c)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
userTokens, err := auth.FetchUserToken(teamSlug)
if err != nil {
log.Error("Error trying to request for credentials", "err", err)
stop()
}
// We will connect to the Eclipse test server (note that you may see messages that other users publish)
u, err := url.Parse(serverURL)
if err != nil {
panic(err)
}
router := paho.NewStandardRouter()
router.DefaultHandler(func(p *paho.Publish) {
infoLogger.Info("Router", "info", fmt.Sprintf("default handler received message with topic: %s\n", p.Topic))
})
cliCfg := autopaho.ClientConfig{
ServerUrls: []*url.URL{u},
ConnectUsername: "", // Must be empty for the authorizer
ConnectPassword: []byte(userTokens.AccessToken),
KeepAlive: 20, // Keepalive message should be sent every 20 seconds
// We don't want the broker to delete any session info when we disconnect
CleanStartOnInitialConnection: true,
SessionExpiryInterval: 60, // Session remains live 60 seconds after disconnect
ReconnectBackoff: autopaho.NewConstantBackoff(time.Second),
OnConnectionUp: func(cm *autopaho.ConnectionManager, connAck *paho.Connack) {
infoLogger.Info("Router", "info", "MQTT connection is up and running")
if _, err := cm.Subscribe(context.Background(), &paho.Subscribe{
Subscriptions: []paho.SubscribeOptions{
{Topic: fmt.Sprintf("%s/#", topic), QoS: 1}, //Listen to all messages from this team
},
}); err != nil {
panic(fmt.Sprintf("failed to subscribe (%s). This is likely to mean no messages will be received.", err))
}
},
Errors: logger{prefix: "subscribe"},
OnConnectError: func(err error) {
infoLogger.Error("Router", "err", fmt.Sprintf("error whilst attempting connection: %s\n", err))
},
// eclipse/paho.golang/paho provides base mqtt functionality, the below config will be passed in for each connection
ClientConfig: paho.ClientConfig{
// If you are using QOS 1/2, then it's important to specify a client id (which must be unique)
ClientID: clientID,
// OnPublishReceived is a slice of functions that will be called when a message is received.
// You can write the function(s) yourself or use the supplied Router
OnPublishReceived: []func(paho.PublishReceived) (bool, error){
func(pr paho.PublishReceived) (bool, error) {
router.Route(pr.Packet.Packet())
return true, nil // we assume that the router handles all messages (todo: amend router API)
}},
OnClientError: func(err error) { infoLogger.Error("Router", "err", fmt.Sprintf("client error: %s\n", err)) },
OnServerDisconnect: func(d *paho.Disconnect) {
if d.Properties != nil {
infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect: %s\n", d.Properties.ReasonString))
} else {
infoLogger.Info("Router", "info", fmt.Sprintf("server requested disconnect; reason code: %d\n", d.ReasonCode))
}
},
},
}
c, err := autopaho.NewConnection(ctx, cliCfg) // starts process; will reconnect until context cancelled
if err != nil {
panic(err)
}
if err = c.AwaitConnection(ctx); err != nil {
panic(err)
}
// Handlers can be registered/deregistered at any time. It's important to note that you need to subscribe AND create
// a handler
//TODO: Have different routes for different things, like starting a session, stopping a session, and stopping the container altogether
//TODO: Listen on team-slug/container-hostname topic only
router.RegisterHandler(fmt.Sprintf("%s/%s/start", topic, hostname), func(p *paho.Publish) {
infoLogger.Info("Router", "info", fmt.Sprintf("start a game: %s\n", p.Topic))
})
router.RegisterHandler(fmt.Sprintf("%s/%s/stop", topic, hostname), func(p *paho.Publish) { fmt.Printf("stop the game that is running: %s\n", p.Topic) })
router.RegisterHandler(fmt.Sprintf("%s/%s/download", topic, hostname), func(p *paho.Publish) { fmt.Printf("download a game: %s\n", p.Topic) })
router.RegisterHandler(fmt.Sprintf("%s/%s/quit", topic, hostname), func(p *paho.Publish) { stop() }) // Stop and quit this running container
// We publish three messages to test out the various route handlers
// topics := []string{"test/test", "test/test/foo", "test/xxNoMatch", "test/quit"}
// for _, t := range topics {
// if _, err := c.Publish(ctx, &paho.Publish{
// QoS: 1,
// Topic: fmt.Sprintf("%s/%s", topic, t),
// Payload: []byte("TestMessage on topic: " + t),
// }); err != nil {
// if ctx.Err() == nil {
// panic(err) // Publish will exit when context cancelled or if something went wrong
// }
// }
// }
<-c.Done() // Wait for clean shutdown (cancelling the context triggered the shutdown)
}

View File

@@ -0,0 +1,31 @@
package party
import (
"fmt"
"os"
"time"
"math/rand"
"github.com/charmbracelet/log"
"github.com/oklog/ulid/v2"
)
var (
infoLogger = log.NewWithOptions(os.Stderr, log.Options{
ReportTimestamp: true,
TimeFormat: time.Kitchen,
// Prefix: "Realtime",
})
)
func generateClientID() string {
// Create a source of entropy (use cryptographically secure randomness in production)
entropy := rand.New(rand.NewSource(time.Now().UnixNano()))
// Generate a new ULID
id := ulid.MustNew(ulid.Timestamp(time.Now()), entropy)
// Create the client ID string
return fmt.Sprintf("client_%s", id.String())
}

View File

@@ -17,6 +17,14 @@ type resource struct {
AuthFingerprintKey struct {
Value string `json:"value"`
}
Party struct {
Endpoint string `json:"endpoint"`
Authorizer string `json:"authorizer"`
}
App struct {
Name string `json:"name"`
Stage string `json:"stage"`
}
}
var Resource resource

View File

@@ -2,57 +2,8 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientID": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}
export {}

View File

@@ -2,6 +2,13 @@
@tailwind base;
@tailwind utilities;
@font-face {
font-family: 'Basement Grotesque';
font-style: normal;
font-display: swap;
font-weight: 800;
src: url("/fonts/BasementGrotesque-Black.woff2") format('woff2'), url("/fonts/BasementGrotesque-Black.woff") format('woff');
}
@layer base {
@@ -18,23 +25,23 @@
}
*::selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.100");
color: theme("colors.primary.500");
}
*::-moz-selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.100");
color: theme("colors.primary.500");
}
html.dark *::selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.500");
color: theme("colors.primary.100");
}
html.dark *::-moz-selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.500");
color: theme("colors.primary.100");
}
html.dark,
@@ -45,13 +52,13 @@
@media (prefers-color-scheme: dark) {
*::selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.500");
color: theme("colors.primary.100");
}
*::-moz-selection {
background-color: theme("colors.gray.400");
color: theme("colors.gray.800");
background-color: theme("colors.primary.500");
color: theme("colors.primary.100");
}
html,
@@ -280,6 +287,27 @@
animation-name: slideToRight;
}
.tooltip[data-closing],
.tooltip[data-closed] {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
touch-action: none;
will-change: transform;
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
animation-name: fadeOut;
}
.tooltip[data-opening],
.tooltip[data-opened] {
animation-duration: 0.5s;
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
touch-action: none;
will-change: transform;
transition: transform 0.5s cubic-bezier(0.32, 0.72, 0, 1);
animation-name: fadeIn;
}
.modal[data-closing]::backdrop,
.modal-sheet[data-closing]::backdrop {
animation-duration: 0.5s;
@@ -460,4 +488,33 @@
100% {
opacity: .15;
}
}
@keyframes bgRotate {
to {
content: var(--tw-content);
transform: rotate(1turn)
}
}
@keyframes playing {
0%,
to {
height: 3px
}
50% {
height: 12px
}
}
.shadow-browser {
box-shadow: 0 30.0333px 63.0111px rgba(45, 48, 57, .09), 0 12.5472px 26.3245px rgba(45, 48, 57, .065), 0 6.70834px 14.0744px rgba(45, 48, 57, .054), 0 3.76064px 7.88997px rgba(45, 48, 57, .045), 0 1.99725px 4.1903px rgba(45, 48, 57, .036), 0 .831099px 1.74368px rgba(45, 48, 57, .025);
border-radius: 7.07px;
}
.bg-radial-gradient {
filter: blur(32px);
background-image: linear-gradient(90deg, rgb(239, 118, 70), rgb(251, 91, 88), rgb(255, 61, 116), rgb(249, 33, 149), rgb(227, 34, 188), rgb(181, 94, 230), rgb(118, 128, 252), rgb(0, 150, 255), rgb(0, 183, 255), rgb(0, 208, 242), rgb(0, 227, 184), rgb(70, 239, 111));;
}

View File

@@ -28,6 +28,7 @@
"@fontsource/bricolage-grotesque": "^5.0.7",
"@fontsource/geist-mono": "^5.1.0",
"@fontsource/geist-sans": "^5.1.0",
"@fontsource/mona-sans": "^5.0.1",
"@modular-forms/qwik": "^0.29.0",
"@nestri/core": "*",
"@qwik-ui/headless": "^0.6.4",

View File

@@ -1,19 +1,22 @@
import { component$, Slot } from "@builder.io/qwik";
// import SansNormal from "@fontsource/geist-sans/400.css?inline"
//font-sans or font-body
import "@fontsource/geist-sans/400.css"
import "@fontsource/geist-sans/500.css"
import "@fontsource/geist-sans/600.css"
import "@fontsource/geist-sans/700.css"
import "@fontsource/bricolage-grotesque/500.css"
import "@fontsource/bricolage-grotesque/700.css"
import "@fontsource/bricolage-grotesque/800.css"
// font mono
import "@fontsource/geist-mono/400.css"
import "@fontsource/geist-mono/700.css"
//font-mona
import "@fontsource-variable/mona-sans"
// import "@fontsource/mona-sans/500.css"
// import "@fontsource/mona-sans/600.css"
// import "@fontsource/mona-sans/700.css"
// import "@fontsource/mona-sans/800.css"
//font-bricolage
import '@fontsource-variable/bricolage-grotesque';
export const Fonts = component$(() => {
// useStyles$(SansNormal);
return <Slot />;
});

View File

@@ -1,134 +0,0 @@
/* eslint-disable qwik/jsx-img */
import { component$ } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";
import { MotionComponent, transition } from "@nestri/ui/react"
import { FooterBanner } from "./footer-banner";
const socialMedia = [
{
link: "https://github.com/nestriness/nestri",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 .999c-6.074 0-11 5.05-11 11.278c0 4.983 3.152 9.21 7.523 10.702c.55.104.727-.246.727-.543v-2.1c-3.06.683-3.697-1.33-3.697-1.33c-.5-1.304-1.222-1.65-1.222-1.65c-.998-.7.076-.686.076-.686c1.105.08 1.686 1.163 1.686 1.163c.98 1.724 2.573 1.226 3.201.937c.098-.728.383-1.226.698-1.508c-2.442-.286-5.01-1.253-5.01-5.574c0-1.232.429-2.237 1.132-3.027c-.114-.285-.49-1.432.107-2.985c0 0 .924-.303 3.026 1.156c.877-.25 1.818-.375 2.753-.38c.935.005 1.876.13 2.755.38c2.1-1.459 3.023-1.156 3.023-1.156c.598 1.554.222 2.701.108 2.985c.706.79 1.132 1.796 1.132 3.027c0 4.332-2.573 5.286-5.022 5.565c.394.35.754 1.036.754 2.088v3.095c0 .3.176.652.734.542C19.852 21.484 23 17.258 23 12.277C23 6.048 18.075.999 12 .999" /></svg>
)
},
{
link: "https://discord.com/invite/Y6etn3qKZ3",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M20.317 4.492c-1.53-.69-3.17-1.2-4.885-1.49a.075.075 0 0 0-.079.036c-.21.369-.444.85-.608 1.23a18.6 18.6 0 0 0-5.487 0a12 12 0 0 0-.617-1.23A.08.08 0 0 0 8.562 3c-1.714.29-3.354.8-4.885 1.491a.1.1 0 0 0-.032.027C.533 9.093-.32 13.555.099 17.961a.08.08 0 0 0 .031.055a20 20 0 0 0 5.993 2.98a.08.08 0 0 0 .084-.026a14 14 0 0 0 1.226-1.963a.074.074 0 0 0-.041-.104a13 13 0 0 1-1.872-.878a.075.075 0 0 1-.008-.125q.19-.14.372-.287a.08.08 0 0 1 .078-.01c3.927 1.764 8.18 1.764 12.061 0a.08.08 0 0 1 .079.009q.18.148.372.288a.075.075 0 0 1-.006.125q-.895.515-1.873.877a.075.075 0 0 0-.041.105c.36.687.772 1.341 1.225 1.962a.08.08 0 0 0 .084.028a20 20 0 0 0 6.002-2.981a.08.08 0 0 0 .032-.054c.5-5.094-.838-9.52-3.549-13.442a.06.06 0 0 0-.031-.028M8.02 15.278c-1.182 0-2.157-1.069-2.157-2.38c0-1.312.956-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.956 2.38-2.157 2.38m7.975 0c-1.183 0-2.157-1.069-2.157-2.38c0-1.312.955-2.38 2.157-2.38c1.21 0 2.176 1.077 2.157 2.38c0 1.312-.946 2.38-2.157 2.38" /></svg>)
},
{
link: "https://www.reddit.com/r/nestri",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286A.72.72 0 0 0 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0m4.388 3.199a1.999 1.999 0 1 1-1.947 2.46v.002a2.37 2.37 0 0 0-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363a2.802 2.802 0 1 1 2.908 4.753c-.088 3.256-3.637 5.876-7.997 5.876c-4.361 0-7.905-2.617-7.998-5.87a2.8 2.8 0 0 1 1.189-5.34c.645 0 1.239.218 1.712.585c1.275-.79 2.881-1.291 4.64-1.365v-.01a3.23 3.23 0 0 1 2.88-3.207a2 2 0 0 1 1.959-1.595m-8.085 8.376c-.784 0-1.459.78-1.506 1.797s.64 1.429 1.426 1.429s1.371-.369 1.418-1.385s-.553-1.841-1.338-1.841m7.406 0c-.786 0-1.385.824-1.338 1.841s.634 1.385 1.418 1.385c.785 0 1.473-.413 1.426-1.429c-.046-1.017-.721-1.797-1.506-1.797m-3.703 4.013c-.974 0-1.907.048-2.77.135a.222.222 0 0 0-.183.305a3.2 3.2 0 0 0 2.953 1.964a3.2 3.2 0 0 0 2.953-1.964a.222.222 0 0 0-.184-.305a28 28 0 0 0-2.769-.135" /></svg>)
},
{
link: "https://x.com/nestriness",
icon: () => (
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M8 2H1l8.26 11.015L1.45 22H4.1l6.388-7.349L16 22h7l-8.608-11.478L21.8 2h-2.65l-5.986 6.886zm9 18L5 4h2l12 16z" /></svg>
)
},
]
type Props = {
showBanner?: boolean
}
export const Footer = component$(({ showBanner = true }: Props) => {
return (
<>
{showBanner && <FooterBanner />}
<footer class="flex justify-center flex-col items-center w-full pt-8 sm:pb-0 pb-8 [&>*]:w-full px-3">
<MotionComponent
initial={{ opacity: 0, y: 50 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={transition}
client:load
as="div"
class="w-full max-w-xl mx-auto z-[5] flex flex-col border-t-2 dark:border-gray-50/50 border-gray-950/50">
<section class="flex justify-between items-center py-6 border-b-2 dark:border-gray-50/50 border-gray-950/50" >
<div class="flex flex-row gap-1 h-max items-center justify-center">
<svg
width={40} height={40}
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<g id="layer1">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</g>
</svg>
<h3 class="text-lg font-extrabold font-title">Nestri</h3>
</div>
<p class="text-gray-950 dark:text-gray-50">Your games. Your rules.</p>
</section>
<section class="gap-4 grid grid-cols-2 sm:grid-cols-3 justify-around py-6 items-start" >
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Product</h2>
<div class="text-gray-950/50 dark:text-gray-50/50 flex flex-col gap-2" >
<p class="text-base opacity-50 cursor-not-allowed" >Docs</p>
<Link href="/pricing" class="text-base hover:text-gray-950 dark:hover:text-gray-50 transition-all duration-200 hover:underline hover:underline-offset-4" >Pricing</Link>
<Link href="/about" class="text-base hover:text-gray-950 dark:hover:text-gray-50 transition-all duration-200 hover:underline hover:underline-offset-4" >About Us</Link>
</div>
</div>
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Company</h2>
<div class="text-gray-950/50 dark:text-gray-50/50 flex flex-col gap-2" >
{/* <Link href="/blog" class="text-base hover:text-gray-950 dark:hover:text-gray-50 transition-all duration-200 hover:underline hover:underline-offset-4" >Blog</Link> */}
{/* <Link href="/contact" class="text-base hover:text-gray-950 dark:hover:text-gray-50 transition-all duration-200 hover:underline hover:underline-offset-4" >Contact Us</Link> */}
<p class="text-base opacity-50 cursor-not-allowed" >Blog</p>
<p class="text-base opacity-50 cursor-not-allowed" >Contact Us</p>
<p class="text-base opacity-50 cursor-not-allowed" >Open Startup</p>
</div>
</div>
<div class="flex flex-col gap-2">
<h2 class="font-title text-sm font-bold" >Relations</h2>
<div class="text-gray-950/50 dark:text-gray-50/50 flex flex-col gap-2" >
<Link href="/privacy" class="text-base hover:text-gray-950 dark:hover:text-gray-50 hover:underline underline-offset-4 transition-all duration-200" >Privacy Policy</Link>
<Link href="/terms" class="text-base hover:text-gray-950 dark:hover:text-gray-50 hover:underline underline-offset-4 transition-all duration-200" >Terms of Service</Link>
{/**Social Media Icons with Links */}
<div class="flex flex-row gap-3">
{socialMedia.map((item) => (
<Link key={item.link} href={item.link} class=" hover:text-gray-950 dark:hover:text-gray-50" target="_blank" rel="noopener noreferrer">
{item.icon()}
</Link>
))}
</div>
</div>
</div>
</section>
</MotionComponent>
<MotionComponent
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{
...transition,
duration: 0.8,
delay: 0.7
}}
client:load
as="div" class="w-full sm:flex z-[1] hidden pointer-events-none overflow-hidden -mt-[100px] justify-center items-center flex-col" >
<section class='my-0 bottom-0 text-[100%] max-w-[1440px] pointer-events-none w-full flex items-center translate-y-[45%] justify-center relative overflow-hidden px-2 z-10 [&_svg]:w-full [&_svg]:max-w-[1440px] [&_svg]:h-full [&_svg]:opacity-70' >
<svg viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} class="" >
<g stroke-linecap="round" fill-rule="evenodd" font-size="9pt" stroke="currentColor" stroke-width="0.25mm" fill="currentColor" style="stroke:currentColor;stroke-width:0.25mm;fill:currentColor">
<path
fill="url(#paint1)"
pathLength="1"
stroke="url(#paint1)"
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
</g>
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
<stop stop-color="white"></stop>
<stop offset="1" stop-opacity="0"></stop>
</linearGradient>
</defs>
</svg>
</section>
</MotionComponent>
</footer>
</>
);
});

View File

@@ -1,34 +1,9 @@
import { SteamLoad, StoreSelect } from "./default";
import { Modal } from "@qwik-ui/headless";
// import { SteamLoad, StoreSelect } from "./default";
import { $, component$, useSignal } from "@builder.io/qwik";
export default component$(() => {
const storeSelect = useSignal(true)
// const storeSelect = useSignal(true)
return (
<Modal.Root class="w-full" >
<Modal.Trigger class="w-full border-gray-400/70 dark:border-gray-700/70 hover:ring-2 hover:ring-[#8f8f8f] dark:hover:ring-[#707070] outline-none group transition-all duration-200 border-[2px] h-14 rounded-xl px-4 gap-2 flex items-center justify-between overflow-hidden bg-white dark:bg-black hover:bg-gray-300/70 dark:hover:bg-gray-700/70 disabled:opacity-50">
<div class="py-2 w-2/3 flex flex-col">
<p class="text-text-100 shrink truncate w-full flex">DESKTOP-EUO8VSF</p>
</div>
<div
style={{
"--cutout-avatar-percentage-visible": 0.2,
"--head-margin-percentage": 0.1,
"--size": "3rem"
}}
class="relative h-full flex w-1/3 justify-end">
<img draggable={false} alt="game" width={256} height={256} src="/images/steam.png" class="h-12 shadow-lg shadow-gray-900 ring-gray-400/70 ring-1 bg-black w-12 translate-y-4 rotate-[14deg] rounded-lg object-cover transition-transform sm:h-16 sm:w-16 group-hover:scale-110" />
</div>
</Modal.Trigger>
<Modal.Panel class="
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] w-full max-w-[360px] max-h-[75vh] rounded-[28px] border dark:border-[#191918] border-[#e2e2e2]
dark:[box-shadow:0_0_0_1px_rgba(255,255,255,0.08),_0_3.3px_2.7px_rgba(0,0,0,.1),0_8.3px_6.9px_rgba(0,0,0,.13),0_17px_14.2px_rgba(0,0,0,.17),0_35px_29.2px_rgba(0,0,0,.22),0px_-4px_4px_0px_rgba(0,0,0,.04)_inset] dark:bg-[#111110]
[box-shadow:0_0_0_1px_rgba(19,21,23,0.08),_0_3.3px_2.7px_rgba(0,0,0,.03),0_8.3px_6.9px_rgba(0,0,0,.04),0_17px_14.2px_rgba(0,0,0,.05),0_35px_29.2px_rgba(0,0,0,.06),0px_-4px_4px_0px_rgba(0,0,0,.07)_inset] bg-[#fdfdfc]
backdrop-blur-lg modal" >
<div class="size-full flex flex-col relative text-gray-800 dark:text-gray-200">
{storeSelect.value ? <StoreSelect onSteamPress$={$(() => { console.log("clicked") })} /> : <SteamLoad />}
</div>
</Modal.Panel>
</Modal.Root>
<></>
)
})

View File

@@ -1,348 +0,0 @@
import { cn } from "./design";
import Avatar from "./avatar"
import { MotionComponent } from "./react";
import { Dropdown, Modal } from '@qwik-ui/headless';
import { disablePageScroll, enablePageScroll } from '@fluejs/noscroll';
import { $, component$, useOnDocument, useSignal } from "@builder.io/qwik";
type Props = {
avatarUrl?: string;
discriminator: string | number;
username: string;
}
export const HomeNavBar = component$(({ avatarUrl, username, discriminator }: Props) => {
const hasScrolled = useSignal(false);
const defaultTeam = `${username}'s Games`
const selectedTeam = useSignal(defaultTeam)
const isNewTeam = useSignal(false);
const isNewMember = useSignal(false);
const isHolding = useSignal(false);
const showInviteSuccess = useSignal(false);
const newTeamName = useSignal('');
const inviteName = useSignal('');
const inviteEmail = useSignal('');
const teams = useSignal([
{ name: defaultTeam }
]);
const onDialogOpen = $((open: boolean) => {
if (open) {
disablePageScroll()
} else {
enablePageScroll()
}
})
const handlePointerDown = $(() => {
isHolding.value = true
});
const handlePointerUp = $(() => {
isHolding.value = false
});
const handleAddTeam = $((e: any) => {
e.preventDefault();
if (newTeamName.value.trim()) {
teams.value = [...teams.value, { name: newTeamName.value.trim() }];
// selectedTeam.value = newTeamName.value.trim()
newTeamName.value = '';
isNewTeam.value = false;
}
});
const handleInvite = $((e: any) => {
e.preventDefault();
if (inviteName.value && inviteEmail.value) {
// Here you would typically make an API call to send the invitation
console.log('Sending invite to:', { name: inviteName.value, email: inviteEmail.value });
inviteName.value = '';
inviteEmail.value = '';
isNewMember.value = false;
showInviteSuccess.value = true;
setTimeout(() => {
showInviteSuccess.value = false;
}, 3000);
}
});
useOnDocument(
'scroll',
$(() => {
hasScrolled.value = window.scrollY > 0;
})
);
const handleDeleteTeam = $(() => {
// Only delete if it's not the default team
if (selectedTeam.value !== defaultTeam) {
teams.value = teams.value.filter(team => team.name !== selectedTeam.value);
selectedTeam.value = defaultTeam;
}
});
const handleDeleteAnimationComplete = $(() => {
if (isHolding.value) {
// isDeleting.value = true;
// Reset the holding state
isHolding.value = false;
handleDeleteTeam();
}
});
return (
<>
<nav class={cn("fixed w-screen justify-between top-0 z-50 px-2 sm:px-6 text-xs sm:text-sm leading-[1] text-gray-950/70 dark:text-gray-50/70 h-[66px] before:backdrop-blur-[15px] before:absolute before:-z-[1] before:top-0 before:left-0 before:w-full before:h-full flex items-center", hasScrolled.value && "shadow-[0_2px_20px_1px] shadow-gray-300 dark:shadow-gray-700")} >
<div class="flex flex-row justify-center relative items-center top-0 bottom-0">
<div class="flex-shrink-0 gap-2 flex justify-center items-center">
<svg
class="size-8 "
width="100%"
height="100%"
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</svg>
</div>
<div class="relative z-[5] animate-fade-in opacity-0 items-center flex">
<hr class="dark:bg-gray-700/70 bg-gray-400/70 w-0.5 rounded-md mx-3 rotate-[16deg] h-7 border-none" />
<Dropdown.Root onOpenChange$={onDialogOpen}>
<Dropdown.Trigger class="text-sm [&>svg:first-child]:size-5 rounded-full h-8 focus:bg-gray-300/70 dark:focus:bg-gray-700/70 focus:ring-[#8f8f8f] dark:focus:ring-[#707070] focus:ring-2 outline-none dark:text-gray-400 text-gray-600 gap-2 px-3 cursor-pointer inline-flex transition-all duration-150 items-center hover:bg-gray-300/70 dark:hover:bg-gray-700/70 ">
<Avatar name={selectedTeam.value} />
<span class="truncate shrink max-w-[20ch]">{selectedTeam.value}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="32" height="32" viewBox="0 0 256 256"><path fill="currentColor" d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" /></svg>
</Dropdown.Trigger>
<Dropdown.Popover
class="bg-[hsla(0,0%,100%,.5)] dark:bg-[hsla(0,0%,100%,.026)] min-w-[160px] max-w-[240px] backdrop-blur-md rounded-lg py-1 px-2 border border-[#e8e8e8] dark:border-[#2e2e2e] [box-shadow:0_8px_30px_rgba(0,0,0,.12)]">
<Dropdown.RadioGroup onChange$={(v: string) => selectedTeam.value = v} value={selectedTeam.value} class="w-full flex overflow-hidden flex-col gap-1 [&_*]:w-full [&_[data-checked]]:bg-[rgba(0,0,0,.071)] dark:[&_[data-checked]]:bg-[hsla(0,0%,100%,.077)] [&_[data-checked]]:rounded-md [&_[data-checked]]:text-[#171717] [&_[data-checked]_svg]:block cursor-pointer [&_[data-highlighted]]:text-[#171717] dark:[&_[data-checked]]:text-[#ededed] dark:[&_[data-highlighted]]:text-[#ededed] [&_[data-highlighted]]:bg-[rgba(0,0,0,.071)] dark:[&_[data-highlighted]]:bg-[hsla(0,0%,100%,.077)] [&_[data-highlighted]]:rounded-md">
{teams.value.map((team) => (
<Dropdown.RadioItem
key={team.name}
value={team.name}
class="leading-none text-sm items-center flex px-2 h-8 rounded-md outline-none relative select-none w-full"
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate [&>svg]:size-5 text-[#6f6f6f] dark:text-[#a0a0a0]">
<Avatar class="flex-shrink-0 rounded-full" name={team.name} />
{team.name}
</span>
<span class="py-1 px-1 text-primary-500 [&>svg]:size-5 [&>svg]:hidden !w-max" >
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 24 24"><path fill="currentColor" d="m10 13.6l5.9-5.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-6.6 6.6q-.3.3-.7.3t-.7-.3l-2.6-2.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275z" /></svg>
</span>
</Dropdown.RadioItem>
))}
</Dropdown.RadioGroup>
<Dropdown.Separator class="w-full dark:bg-[#2e2e2e] bg-[#e8e8e8] border-0 h-[1px] my-1" />
<Dropdown.Group class="flex flex-col gap-1 w-full">
<Dropdown.Item
onClick$={() => isNewTeam.value = true}
class="leading-none w-full text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative"
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14" /></svg>
New Team
</span>
</Dropdown.Item>
<Dropdown.Item
onClick$={() => isNewMember.value = true}
class={cn("leading-none w-full text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative", selectedTeam.value === defaultTeam && "opacity-50 pointer-events-none !cursor-not-allowed")}
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4" /></svg>
Send an invite
</span>
</Dropdown.Item>
<button
onPointerDown$={handlePointerDown}
onPointerUp$={handlePointerUp}
onPointerLeave$={handlePointerUp}
onKeyDown$={(e) => e.key === "Enter" && handlePointerDown()}
onKeyUp$={(e) => e.key === "Enter" && handlePointerUp()}
disabled={selectedTeam.value === defaultTeam}
class={cn("leading-none relative overflow-hidden transition-all duration-200 text-sm group items-center text-red-500 [&>*]:w-full [&>qwik-react]:absolute [&>qwik-react]:h-full [&>qwik-react]:left-0 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none select-none w-full", selectedTeam.value === defaultTeam && "opacity-50 pointer-events-none !cursor-not-allowed")}>
<MotionComponent
client:load
class="absolute left-0 top-0 bottom-0 bg-red-500 opacity-50 w-full h-full rounded-md"
initial={{ scaleX: 0 }}
animate={{
scaleX: isHolding.value ? 1 : 0
}}
style={{
transformOrigin: 'left',
}}
transition={{
duration: isHolding.value ? 2 : 0.5,
ease: "linear"
}}
onAnimationComplete$={handleDeleteAnimationComplete}
/>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19.5 5.5l-.402 6.506M4.5 5.5l.605 10.025c.154 2.567.232 3.85.874 4.774c.317.456.726.842 1.2 1.131c.671.41 1.502.533 2.821.57m10-7l-7 7m7 0l-7-7M3 5.5h18m-4.944 0l-.683-1.408c-.453-.936-.68-1.403-1.071-1.695a2 2 0 0 0-.275-.172C13.594 2 13.074 2 12.035 2c-1.066 0-1.599 0-2.04.234a2 2 0 0 0-.278.18c-.395.303-.616.788-1.058 1.757L8.053 5.5" color="currentColor" /></svg>
<span class="group-hover:hidden">Delete Team</span>
<span class="hidden group-hover:block">Hold to delete</span>
</span>
</button>
</Dropdown.Group>
</Dropdown.Popover>
</Dropdown.Root>
</div>
</div>
<div class="gap-4 flex flex-row justify-center h-full animate-fade-in opacity-0 items-center">
<Dropdown.Root onOpenChange$={onDialogOpen}>
<Dropdown.Trigger class="focus:bg-gray-300/70 dark:focus:bg-gray-700/70 focus:ring-[#8f8f8f] dark:focus:ring-[#707070] text-gray-600 dark:text-gray-400 [&>svg:first-child]:size-5 text-sm focus:ring-2 outline-none rounded-full transition-all flex items-center duration-150 select-none cursor-pointer hover:bg-gray-300/70 dark:hover:bg-gray-700/70 gap-1 px-3 h-8" >
{avatarUrl ? (<img src={avatarUrl} height={20} width={20} class="size-6 rounded-full" alt="Avatar" />) : (<Avatar name={`${username}#${discriminator}`} />)}
<span class="truncate shrink max-w-[20ch] sm:flex hidden">{`${username}#${discriminator}`}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 sm:block hidden" width="32" height="32" viewBox="0 0 256 256"><path fill="currentColor" d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" /></svg>
</Dropdown.Trigger>
<Dropdown.Popover
class="bg-[hsla(0,0%,100%,.5)] dark:bg-[hsla(0,0%,100%,.026)] min-w-[160px] max-w-[240px] backdrop-blur-md rounded-lg py-1 px-2 border border-[#e8e8e8] dark:border-[#2e2e2e] [box-shadow:0_8px_30px_rgba(0,0,0,.12)]">
<Dropdown.Group class="flex flex-col gap-1">
<Dropdown.Item
onClick$={() => window.location.href = "mailto:feedback@nestri.io"}
class="leading-none text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative select-none "
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="currentColor" d="M22 8.5a6.5 6.5 0 0 0-11.626-3.993A9.5 9.5 0 0 1 19.5 14q0 .165-.006.33l.333.088a1.3 1.3 0 0 0 1.592-1.591l-.128-.476c-.103-.385-.04-.791.125-1.153A6.5 6.5 0 0 0 22 8.5" /><path fill="currentColor" fill-rule="evenodd" d="M18 14a8 8 0 0 1-11.45 7.22a1.67 1.67 0 0 0-1.15-.13l-1.227.329a1.3 1.3 0 0 1-1.591-1.592L2.91 18.6a1.67 1.67 0 0 0-.13-1.15A8 8 0 1 1 18 14M6.5 15a1 1 0 1 0 0-2a1 1 0 0 0 0 2m3.5 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2m3.5 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2" clip-rule="evenodd" /></svg>
Send Feedback
</span>
</Dropdown.Item>
<button
onPointerDown$={handlePointerDown}
onPointerUp$={handlePointerUp}
onPointerLeave$={handlePointerUp}
onKeyDown$={(e) => e.key === "Enter" && handlePointerDown()}
onKeyUp$={(e) => e.key === "Enter" && handlePointerUp()}
class="leading-none relative overflow-hidden transition-all duration-200 text-sm group items-center text-red-500 [&>*]:w-full [&>qwik-react]:absolute [&>qwik-react]:h-full [&>qwik-react]:left-0 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none select-none w-full">
<MotionComponent
client:load
class="absolute left-0 top-0 bottom-0 bg-red-500 opacity-50 w-full h-full rounded-md"
initial={{ scaleX: 0 }}
animate={{
scaleX: isHolding.value ? 1 : 0
}}
style={{
transformOrigin: 'left',
}}
transition={{
duration: isHolding.value ? 2 : 0.5,
ease: "linear"
}}
/>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><g fill="none"><path fill="currentColor" fill-rule="evenodd" d="M10.138 1.815A3 3 0 0 1 14 4.688v14.624a3 3 0 0 1-3.862 2.873l-6-1.8A3 3 0 0 1 2 17.512V6.488a3 3 0 0 1 2.138-2.873zM15 4a1 1 0 0 1 1-1h3a3 3 0 0 1 3 3v1a1 1 0 1 1-2 0V6a1 1 0 0 0-1-1h-3a1 1 0 0 1-1-1m6 12a1 1 0 0 1 1 1v1a3 3 0 0 1-3 3h-3a1 1 0 1 1 0-2h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1M9 11a1 1 0 1 0 0 2h.001a1 1 0 1 0 0-2z" clip-rule="evenodd" /><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12h5m0 0l-2-2m2 2l-2 2" /></g></svg>
<span class="group-hover:hidden">Log out</span>
<span class="hidden group-hover:block">Hold to logout</span>
</span>
</button>
</Dropdown.Group>
{/* <Dropdown.Separator class="w-full dark:bg-[#2e2e2e] bg-[#e8e8e8] border-0 h-[1px] my-1" />
<Dropdown.Group class="flex flex-col gap-1">
<Dropdown.Item
class="leading-none transition-all duration-200 text-sm group items-center text-red-500 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative select-none "
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 22H6.59c-1.545 0-2.774-.752-3.877-1.803c-2.26-2.153 1.45-3.873 2.865-4.715a10.67 10.67 0 0 1 7.922-1.187m3-7.795a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0M16 22l3-3m0 0l3-3m-3 3l-3-3m3 3l3 3" color="currentColor" /></svg>
<span class="group-hover:hidden">Leave Team</span>
<span class="hidden group-hover:block">Hold to leave</span>
</span>
</Dropdown.Item>
</Dropdown.Group> */}
</Dropdown.Popover>
</Dropdown.Root>
</div>
</nav>
<Modal.Root bind:show={isNewTeam} class="w-full">
<Modal.Panel
class="dark:bg-black bg-white [box-shadow:0_8px_30px_rgba(0,0,0,.12)]
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] max-h-[75vh] rounded-xl
backdrop-blur-md modal max-w-[400px] w-full border dark:border-gray-800 border-gray-200">
<form preventdefault:submit onSubmit$={handleAddTeam}>
<main class="size-full flex flex-col relative py-4 px-5">
<div class="dark:text-white text-black">
<h3 class="font-semibold text-2xl tracking-tight mb-1 font-title">Create a team</h3>
<div class="text-sm dark:text-gray-200/70 text-gray-800/70" >
Continue to start playing with on Pro with increased usage, additional security features, and support
</div>
</div>
<div class="mt-3 flex flex-col gap-3" >
<div>
<label for="name" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Team Name
</label>
<input
//@ts-expect-error
onInput$={(e) => newTeamName.value = e.target!.value}
required value={newTeamName.value} id="name" type="text" placeholder="Enter team name" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
</div>
</main>
<footer class="dark:text-gray-200/70 text-gray-800/70 dark:bg-gray-900 bg-gray-100 ring-1 ring-gray-200 dark:ring-gray-800 select-none flex gap-2 items-center justify-between w-full bottom-0 left-0 py-3 px-5 text-sm leading-none">
<Modal.Close class="rounded-lg [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] py-3 px-4 hover:bg-gray-200 dark:hover:bg-gray-800 flex items-center justify-center">
Cancel
</Modal.Close>
<button
type="submit"
class="flex items-center justify-center gap-2 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-200 dark:bg-gray-800 py-3 px-4 hover:bg-gray-300 dark:hover:bg-gray-700 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)]" >
Continue
</button>
</footer>
</form>
</Modal.Panel>
</Modal.Root >
<Modal.Root bind:show={isNewMember} class="w-full">
<Modal.Panel
class="dark:bg-black bg-white [box-shadow:0_8px_30px_rgba(0,0,0,.12)]
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] max-h-[75vh] rounded-xl
backdrop-blur-md modal max-w-[400px] w-full border dark:border-gray-800 border-gray-200">
<form preventdefault:submit onSubmit$={handleInvite}>
<main class="size-full flex flex-col relative py-4 px-5">
<div class="dark:text-white text-black">
<h3 class="font-semibold text-2xl tracking-tight mb-1 font-title">Send an invite</h3>
<div class="text-sm dark:text-gray-200/70 text-gray-800/70" >
Friends will receive an email allowing them to join this team
</div>
</div>
<div class="mt-3 flex flex-col gap-3" >
<div>
<label for="name" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Name
</label>
<input
value={inviteName.value}
//@ts-expect-error
onInput$={(e) => inviteName.value = e.target!.value}
id="name" type="text" placeholder="Jane Doe" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
<div>
<label for="email" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Email
</label>
<input
value={inviteEmail.value}
//@ts-expect-error
onInput$={(e) => inviteEmail.value = e.target!.value}
id="email" type="email" placeholder="jane@doe.com" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full px-2 bg-transparent py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
</div>
</main>
<footer class="dark:text-gray-200/70 text-gray-800/70 dark:bg-gray-900 bg-gray-100 ring-1 ring-gray-200 dark:ring-gray-800 select-none flex gap-2 items-center justify-between w-full bottom-0 left-0 py-3 px-5 text-sm leading-none">
<Modal.Close class="rounded-lg [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] py-3 px-4 hover:bg-gray-200 dark:hover:bg-gray-800 flex items-center justify-center">
Cancel
</Modal.Close>
<button type="submit" class="flex items-center justify-center gap-2 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-200 dark:bg-gray-800 py-3 px-4 hover:bg-gray-300 dark:hover:bg-gray-700 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)]" >
Send an invite
</button>
</footer>
</form>
</Modal.Panel>
</Modal.Root>
</>
)
})

View File

@@ -0,0 +1,135 @@
import { cn } from "../design";
import Avatar from "../avatar";
import type Nestri from "@nestri/sdk"
import { Tooltip } from '@qwik-ui/headless';
import { useNavigate } from "@builder.io/qwik-city";
import { $, component$, useOnDocument, useSignal, type QRL } from "@builder.io/qwik";
type Props = {
getActiveUsers$: QRL<() => Promise<Nestri.Users.UserListResponse.Data[] | undefined>>
getSession$: QRL<(profileID: string) => Promise<Nestri.Users.UserSessionResponse | undefined>>
}
const skeletonCrew = new Array(3).fill(0)
export const HomeFriendsSection = component$(({ getActiveUsers$, getSession$ }: Props) => {
const activeUsers = useSignal<Nestri.Users.UserListResponse.Data[] | undefined>()
const nav = useNavigate()
useOnDocument("load", $(async () => {
const sessionUserData = sessionStorage.getItem("active_user_data")
if (!sessionUserData) {
const users = await getActiveUsers$()
sessionStorage.setItem("active_user_data", JSON.stringify(users))
activeUsers.value = users
} else {
activeUsers.value = JSON.parse(sessionUserData)
}
}))
const onWatch = $(async (profileID: string) => {
const session = await getSession$(profileID)
await nav(`/play/${session?.data.id}`)
})
return (
<div class="gap-2 w-full flex-col flex">
<hr class="border-none h-[1.5px] dark:bg-gray-700 bg-gray-300 w-full" />
<div class="flex flex-col justify-center py-2 px-3 items-start w-full ">
<div class="text-gray-600/70 dark:text-gray-400/70 leading-none flex justify-between items-center w-full py-1">
<span class="text-xl text-gray-700 dark:text-gray-300 leading-none font-bold font-title flex gap-2 items-center pb-2">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 20 20"><path fill="currentColor" d="M2.049 9.112a8.001 8.001 0 1 1 9.718 8.692a1.5 1.5 0 0 0-.206-1.865l-.01-.01q.244-.355.47-.837a9.3 9.3 0 0 0 .56-1.592H9.744q.17-.478.229-1h2.82A15 15 0 0 0 13 10c0-.883-.073-1.725-.206-2.5H7.206l-.05.315a4.5 4.5 0 0 0-.971-.263l.008-.052H3.46q-.112.291-.198.595c-.462.265-.873.61-1.213 1.017m9.973-4.204C11.407 3.59 10.657 3 10 3s-1.407.59-2.022 1.908A9.3 9.3 0 0 0 7.42 6.5h5.162a9.3 9.3 0 0 0-.56-1.592M6.389 6.5c.176-.743.407-1.422.683-2.015c.186-.399.401-.773.642-1.103A7.02 7.02 0 0 0 3.936 6.5zm9.675 7H13.61a10.5 10.5 0 0 1-.683 2.015a6.6 6.6 0 0 1-.642 1.103a7.02 7.02 0 0 0 3.778-3.118m-2.257-1h2.733c.297-.776.46-1.62.46-2.5s-.163-1.724-.46-2.5h-2.733c.126.788.193 1.63.193 2.5s-.067 1.712-.193 2.5m2.257-6a7.02 7.02 0 0 0-3.778-3.118c.241.33.456.704.642 1.103c.276.593.507 1.272.683 2.015zm-7.76 7.596a3.5 3.5 0 1 0-.707.707l2.55 2.55a.5.5 0 0 0 .707-.707zM8 12a2.5 2.5 0 1 1-5 0a2.5 2.5 0 0 1 5 0" /></svg>
Find people to play with
</span>
</div>
<ul class="list-none ml-4 relative w-[calc(100%-1rem)]">
{activeUsers.value ? (
activeUsers.value.slice(0, 3).map((user, key) => (
<div key={`user-${key}`} >
<div class="gap-3.5 hover:bg-gray-200 dark:hover:bg-gray-800 text-left outline-none group rounded-lg px-3 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] flex items-center w-full">
<div class="relative [&>svg]:size-[80px] [&>img]:size-[80px] min-w-[80px]">
{user.avatarUrl ?
(<img height={52} width={52} draggable={false} class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] ring-2 ring-gray-200 dark:ring-gray-800 select-none rounded-full aspect-square w-[80px]" src={user.avatarUrl} alt={user.username} />) :
(<Avatar name={`${user.username}#${user.discriminator}`} />)}
</div>
<div class={cn("w-full h-[100px] overflow-hidden pr-2 border-b-2 border-gray-400/70 dark:border-gray-700/70 flex gap-2 items-center", key == 2 && "border-none")}>
<div class="flex-col">
<span class="font-medium tracking-tighter text-gray-700 dark:text-gray-300 max-w-full text-lg truncate leading-none flex [&>svg]:size-5 [&>svg]:dark:text-[#12ECFA] ">
{`${user.username}`}&nbsp;<p class="hidden group-hover:block text-gray-600/70 dark:text-gray-400/70 transition-all duration-200 ease-in">{` #${user.discriminator}`}</p>
{/* &nbsp;{user.status && (user.status == "active" && (<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M10.277 16.515c.005-.11.186-.154.24-.058c.254.45.686 1.111 1.176 1.412s1.276.386 1.792.408c.11.005.153.186.057.24c-.45.254-1.11.686-1.411 1.176s-.386 1.276-.408 1.792c-.005.11-.187.153-.24.057c-.254-.45-.686-1.11-1.177-1.411c-.49-.301-1.276-.386-1.791-.408c-.11-.005-.154-.187-.058-.24c.45-.254 1.111-.686 1.412-1.177c.3-.49.386-1.276.408-1.791"/><path fill="currentColor" d="M18.492 15.515c-.009-.11-.2-.156-.258-.062c-.172.283-.42.623-.697.793s-.692.236-1.022.262c-.11.008-.156.2-.062.257c.282.172.623.42.793.697s.236.693.262 1.023c.008.11.2.155.257.061c.172-.282.42-.623.697-.792s.693-.237 1.023-.262c.11-.009.155-.2.061-.258c-.282-.172-.623-.42-.792-.697s-.237-.692-.262-1.022" opacity=".5"/><path fill="currentColor" d="m14.703 4.002l-.242-.306c-.937-1.183-1.405-1.775-1.95-1.688c-.544.088-.805.796-1.326 2.213l-.135.366c-.148.403-.222.604-.364.752s-.336.225-.724.38l-.353.141l-.247.1c-1.2.48-1.804.753-1.882 1.283c-.082.565.49 1.049 1.634 2.016l.296.25c.326.275.488.413.581.6c.094.187.107.403.133.835l.024.393c.094 1.52.14 2.28.635 2.542c.494.262 1.108-.147 2.336-.966l.318-.212c.349-.233.523-.35.723-.381s.401.024.806.136l.367.102c1.423.394 2.134.591 2.521.188c.388-.403.195-1.14-.19-2.613l-.1-.381c-.109-.419-.164-.628-.134-.835s.142-.389.366-.752l.203-.33c.785-1.276 1.178-1.914.924-2.426c-.255-.51-.988-.557-2.454-.648l-.38-.024c-.416-.026-.624-.039-.805-.135s-.314-.264-.58-.6"/><path fill="currentColor" d="M8.835 13.326C6.698 14.37 4.919 16.024 4.248 18c-.752-4.707.292-7.747 1.965-9.637c.144.295.332.539.5.73c.35.396.852.82 1.362 1.251l.367.31l.17.145c.005.064.01.14.015.237l.03.485c.04.655.08 1.294.178 1.805" opacity=".5"/></svg>))} */}
</span>
<div class="flex items-center gap-2 w-full cursor-pointer px-1 rounded-md">
<div class={cn("font-normal capitalize w-full text-gray-600/70 dark:text-gray-400/70 truncate flex gap-1 items-center", (user.status && user.status == "active") && "dark:text-[#50e3c2]")}>
<div class={cn("size-3 rounded-full block", user.status ? (user.status == "active" ? "bg-[#50e3c2]" : user.status == "idle" ? "bg-[#ff990a]" : "hidden") : "hidden")} />
{user.status ? (user.status == "active" ? "Playing Steam" : user.status) : "Offline"}
</div>
</div>
</div>
<div class="ml-auto relative flex gap-2 justify-center h-full items-center">
{user.status && (user.status == "active" &&
(
<Tooltip.Root gutter={12} flip={false} placement="top" >
<Tooltip.Trigger onClick$={async () => { await onWatch(user.id) }} type="button" class="bg-gray-200 group-hover:bg-gray-300 dark:group-hover:bg-gray-700 transition-all duration-200 ease-in text-gray-600 dark:text-gray-400 dark:bg-gray-800 [&>svg]:size-5 p-2 rounded-full" >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 16 16"><path fill="currentColor" fill-rule="evenodd" d="M4.75 5.5c.427 0 .791.163 1.075.45c-.145-.778-.37-1.415-.64-1.894C4.72 3.236 4.216 3 3.75 3s-.97.237-1.434 1.056C1.835 4.906 1.5 6.25 1.5 8s.335 3.094.816 3.944c.463.82.967 1.056 1.434 1.056s.97-.237 1.434-1.056c.272-.48.496-1.116.64-1.895a1.47 1.47 0 0 1-1.074.451C3.674 10.5 3 9.47 3 8s.674-2.5 1.75-2.5M7.5 8c0 3.822-1.445 6.5-3.75 6.5S0 11.822 0 8s1.445-6.5 3.75-6.5S7.5 4.178 7.5 8m6.825 2.05c-.145.778-.37 1.415-.64 1.894c-.464.82-.968 1.056-1.435 1.056s-.97-.237-1.434-1.056C10.335 11.094 10 9.75 10 8s.335-3.094.816-3.944C11.279 3.236 11.783 3 12.25 3s.97.237 1.434 1.056c.272.48.496 1.116.64 1.895A1.47 1.47 0 0 0 13.25 5.5c-1.076 0-1.75 1.03-1.75 2.5s.674 2.5 1.75 2.5a1.47 1.47 0 0 0 1.075-.45M16 8c0 3.822-1.445 6.5-3.75 6.5S8.5 11.822 8.5 8s1.445-6.5 3.75-6.5S16 4.178 16 8" clip-rule="evenodd" /></svg>
</Tooltip.Trigger>
<Tooltip.Panel class="tooltip capitalize text-sm relative rounded-md dark:bg-gray-700 bg-gray-300 py-2 px-3 text-gray-700 dark:text-gray-300" aria-label="More options">
Watch stream
<div class="-bottom-2 left-1/2 -translate-x-1/2 absolute dark:text-gray-700 text-gray-300" >
<svg width="15" height="10" viewBox="0 0 30 10" preserveAspectRatio="none" fill="currentColor"><polygon points="0,0 30,0 15,10"></polygon></svg>
</div>
</Tooltip.Panel>
</Tooltip.Root>
))}
<Tooltip.Root gutter={12} flip={false} placement="top" >
<Tooltip.Trigger disabled type="button" class="bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed group-hover:bg-gray-300 dark:group-hover:bg-gray-700 transition-all duration-200 ease-in text-gray-600 dark:text-gray-400 dark:bg-gray-800 [&>svg]:size-5 p-2 rounded-full" >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4" /></svg>
</Tooltip.Trigger>
<Tooltip.Panel class="tooltip capitalize text-sm relative rounded-md dark:bg-gray-700 bg-gray-300 py-2 px-3 text-gray-700 dark:text-gray-300" aria-label="More options">
Invite
<div class="-bottom-2 left-1/2 -translate-x-1/2 absolute dark:text-gray-700 text-gray-300" >
<svg width="15" height="10" viewBox="0 0 30 10" preserveAspectRatio="none" fill="currentColor"><polygon points="0,0 30,0 15,10"></polygon></svg>
</div>
</Tooltip.Panel>
</Tooltip.Root>
<Tooltip.Root gutter={12} flip={false} placement="top" >
<Tooltip.Trigger disabled type="button" class="bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed group-hover:bg-gray-300 dark:group-hover:bg-gray-700 transition-all duration-200 ease-in text-gray-600 dark:text-gray-400 dark:bg-gray-800 [&>svg]:size-5 p-2 rounded-full" >
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2s-2 .9-2 2s.9 2 2 2m0 2c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2m0 6c-1.1 0-2 .9-2 2s.9 2 2 2s2-.9 2-2s-.9-2-2-2" /></svg>
</Tooltip.Trigger>
<Tooltip.Panel class="tooltip capitalize text-sm relative rounded-md dark:bg-gray-700 bg-gray-300 py-2 px-3 text-gray-700 dark:text-gray-300" aria-label="More options">
more
<div class="-bottom-2 left-1/2 -translate-x-1/2 absolute dark:text-gray-700 text-gray-300" >
<svg width="15" height="10" viewBox="0 0 30 10" preserveAspectRatio="none" fill="currentColor"><polygon points="0,0 30,0 15,10"></polygon></svg>
</div>
</Tooltip.Panel>
</Tooltip.Root>
</div>
</div>
</div>
</div>
))
) :
(
skeletonCrew.map((_, key) => (
<div key={`skeleton-friend-${key}`} >
<div class="gap-3.5 text-left animate-pulse outline-none group rounded-lg px-3 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] flex items-center w-full">
<div class="relative w-max">
<div class="size-20 rounded-full bg-gray-200 dark:bg-gray-800" />
</div>
<div class={cn("w-full h-[100px] overflow-hidden pr-2 border-b-2 border-gray-400/70 dark:border-gray-700/70 flex gap-2 items-center", key == 2 && "border-none")}>
<div class="flex-col w-[80%] gap-2 flex">
<span class="font-medium tracking-tighter bg-gray-200 dark:bg-gray-800 rounded-md h-6 w-2/3 max-w-full text-lg font-title truncate leading-none block" />
<div class="flex items-center gap-2 w-full h-6 bg-gray-200 dark:bg-gray-800 rounded-md" />
</div>
<div class="bg-gray-200 dark:bg-gray-800 h-7 w-16 ml-auto rounded-md" />
</div>
</div>
</div>
))
)
}
<div class="[border:1px_dashed_theme(colors.gray.300)] dark:[border:1px_dashed_theme(colors.gray.800)] [mask-image:linear-gradient(rgb(0,0,0)_0%,_rgb(0,0,0)_calc(100%-120px),_transparent_100%)] bottom-0 top-0 -left-[0.4625rem] absolute" />
</ul>
</div>
</div>
)
})

View File

@@ -0,0 +1,102 @@
import type Nestri from "@nestri/sdk";
import { useNavigate } from "@builder.io/qwik-city";
import { $, component$, useOnDocument, useSignal, type QRL } from "@builder.io/qwik";
type Props = {
getUserSubscription$: QRL<() => Promise<"Free" | "Pro" | undefined>>
createSession$: QRL<() => Promise<Nestri.Tasks.TaskSessionResponse.Data | undefined>>
}
const skeletonGames = new Array(6).fill(0)
export const HomeGamesSection = component$(({ getUserSubscription$ }: Props) => { //createSession$
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const nav = useNavigate()
const creatingSession = useSignal(false)
const userSubscription = useSignal<"Free" | "Pro" | undefined>()
useOnDocument("load", $(async () => {
const userSub = sessionStorage.getItem("subscription_data")
if (userSub) {
userSubscription.value = JSON.parse(userSub)
} else {
const subscription = await getUserSubscription$()
sessionStorage.setItem("subscription_data", JSON.stringify(subscription))
userSubscription.value = subscription
}
}))
const onClick = $(async () => {
console.log("clicked")
// creatingSession.value = true
// const sessionID = await createSession$()
// if (sessionID) {
// creatingSession.value = false
// await nav(`/play/${sessionID.id}`)
// }
});
return (
<div class="gap-2 w-full flex-col flex">
<hr class="border-none h-[1.5px] dark:bg-gray-700 bg-gray-300 w-full" />
<div class="text-gray-600/70 dark:text-gray-400/70 text-sm leading-none flex justify-start py-2 px-3 items-end">
<span class="text-xl text-gray-700 dark:text-gray-300 leading-none font-bold font-title flex gap-2 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M12 22c-.818 0-1.6-.33-3.163-.99C4.946 19.366 3 18.543 3 17.16V7m9 15c.818 0 1.6-.33 3.163-.99C19.054 19.366 21 18.543 21 17.16V7m-9 15V11.355M8.326 9.691L5.405 8.278C3.802 7.502 3 7.114 3 6.5s.802-1.002 2.405-1.778l2.92-1.413C10.13 2.436 11.03 2 12 2s1.871.436 3.674 1.309l2.921 1.413C20.198 5.498 21 5.886 21 6.5s-.802 1.002-2.405 1.778l-2.92 1.413C13.87 10.564 12.97 11 12 11s-1.871-.436-3.674-1.309M6 12l2 1m9-9L7 9" color="currentColor" /></svg>
Your Games
</span>
{/* {userSubscription.value ? (
<button disabled={userSubscription.value === "Free"} class="disabled:opacity-50 disabled:cursor-not-allowed ml-auto flex gap-1 items-center cursor-pointer [&:not(:disabled)]:hover:text-gray-800 dark:[&:not(:disabled)]:hover:text-gray-200 transition-all duration-200 outline-none">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 256 256"><path fill="currentColor" d="M248 128a87.34 87.34 0 0 1-17.6 52.81a8 8 0 1 1-12.8-9.62A71.34 71.34 0 0 0 232 128a72 72 0 0 0-144 0a8 8 0 0 1-16 0a88 88 0 0 1 3.29-23.88C74.2 104 73.1 104 72 104a48 48 0 0 0 0 96h24a8 8 0 0 1 0 16H72a64 64 0 1 1 9.29-127.32A88 88 0 0 1 248 128m-69.66 42.34L160 188.69V128a8 8 0 0 0-16 0v60.69l-18.34-18.35a8 8 0 0 0-11.32 11.32l32 32a8 8 0 0 0 11.32 0l32-32a8 8 0 0 0-11.32-11.32" /></svg>
<span>Install a game</span>
</button>
) : (
<div class="ml-auto h-4 w-28 rounded-md bg-gray-200 dark:gray-800 animate-pulse" />
)} */}
</div>
<ul class="relative py-3 w-full list-none after:pointer-events-none after:select-none after:w-full after:h-[120px] after:fixed after:z-10 after:backdrop-blur-[1px] after:bg-gradient-to-b after:from-transparent after:to-gray-200 dark:after:to-gray-800 after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.gray.200)_25%,transparent)] dark:after:[-webkit-mask-image:linear-gradient(to_top,theme(colors.gray.800)_25%,transparent)] after:left-0 after:-bottom-[1px]">
{userSubscription.value ? (
<div class="flex flex-col items-center justify-center gap-6 px-6 py-20 w-full" >
<div class="relative flex items-center justify-center overflow-hidden rounded-[22px] p-[2px] before:absolute before:left-[-50%] before:top-[-50%] before:z-[-2] before:h-[200%] before:w-[200%] before:animate-[bgRotate_1.15s_linear_infinite] before:bg-[conic-gradient(from_0deg,transparent_0%,#ff4f01_10%,#ff4f01_25%,transparent_35%)] before:content-[''] after:absolute after:inset-[2px] after:z-[-1] after:content-['']" >
<div class="flex items-center justify-center rounded-[20px] bg-gray-200 dark:bg-gray-800 p-1">
<div class="flex items-center justify-center rounded-2xl bg-[#F5F5F5] p-1 dark:bg-[#171717]">
<div class="flex h-[64px] w-[64px] items-center justify-center rounded-xl bg-gray-100 dark:bg-gray-900">
<svg xmlns="http://www.w3.org/2000/svg" width="32" class="h-8 w-8 shrink-0 dark:text-gray-700 text-gray-300" height="32" viewBox="0 0 24 24"><path fill="currentColor" d="M11.968 2C6.767 2 2.4 6.045 2.048 11.181l5.329 2.216c.45-.322.995-.45 1.573-.45h.128l2.344-3.5v-.031a3.74 3.74 0 0 1 3.756-3.756c2.087 0 3.788 1.67 3.788 3.756a3.74 3.74 0 0 1-3.756 3.756h-.096l-3.403 2.44v.128a2.863 2.863 0 0 1-2.857 2.857c-1.349 0-2.536-.995-2.761-2.247l-3.724-1.637C3.557 18.886 7.44 22 11.968 22c5.49-.032 9.984-4.494 9.984-10.016S17.457 2 11.968 2" /><path fill="currentColor" d="m8.276 17.152l-1.22-.481c.225.45.578.867 1.092 1.027c1.027.45 2.311-.032 2.76-1.123a2.07 2.07 0 0 0 0-1.638a2.26 2.26 0 0 0-1.123-1.187c-.514-.225-1.027-.193-1.54-.033l1.251.546c.77.353 1.188 1.252.867 2.023c-.353.802-1.252 1.155-2.087.866m9.502-7.736c0-1.349-1.124-2.536-2.536-2.536c-1.349 0-2.536 1.123-2.536 2.536c0 1.412 1.188 2.536 2.536 2.536s2.536-1.156 2.536-2.536m-4.366 0c0-1.027.867-1.862 1.862-1.862c1.027 0 1.862.867 1.862 1.862c0 1.027-.867 1.862-1.862 1.862c-1.027.032-1.862-.835-1.862-1.862" /></svg>
</div>
</div>
</div>
</div>
<div class="flex flex-col items-center justify-center gap-1">
<span class="select-none text-center text-gray-700 dark:text-gray-300 font-title text-xl font-semibold sm:font-medium">Waiting for your first game install</span>
<p class="text-center text-base font-medium text-gray-600 dark:text-gray-400 sm:font-regular">Once you have installed a game on your machine, it should appear here</p>
</div>
<button
onClick$={onClick}
// disabled={userSubscription.value === "Free"}
disabled
class="flex gap-2 h-[48px] disabled:cursor-not-allowed disabled:opacity-50 max-w-[360px] w-full select-none items-center justify-center rounded-full bg-primary-500 text-base font-semibold text-white transition-all duration-200 ease-out [&:not(:disabled)]:hover:ring-2 [&:not(:disabled)]:hover:ring-gray-600 dark:[&:not(:disabled)]:hover:ring-gray-400 [&:not(:disabled)]:focus:scale-95 [&:not(:disabled)]:active:scale-95 sm:font-medium">
{creatingSession.value &&
<div style={{ "--spinner-color": "#FFF" }} data-component="spinner">
<div>
{new Array(12).fill(0).map((i, k) => (
<div key={k} />
))}
</div>
</div>
}
<span> {creatingSession.value ? "Launching Steam" : "Launch Steam"}</span>
</button>
</div>
) : (
<div class="grid sm:grid-cols-3 grid-cols-2 gap-2 gap-y-3 w-full animate-pulse" >
{skeletonGames.map((_, key) => (
<div key={`skeleton-game-${key}`} class="w-full gap-2 flex flex-col" >
<div class="bg-gray-200 dark:bg-gray-800 w-full aspect-square rounded-2xl" />
</div>
))}
</div>
)}
</ul >
</div >
)
})

View File

@@ -0,0 +1,131 @@
/* eslint-disable qwik/jsx-img */
import { cn } from "../design";
import { MotionComponent } from "../react";
import { $, component$, useOnDocument, useSignal, type QRL } from "@builder.io/qwik";
import { Link } from "@builder.io/qwik-city";
type Props = {
getUserSubscription$: QRL<() => Promise<"Free" | "Pro" | undefined>>
}
export const HomeMachineSection = component$(({ getUserSubscription$ }: Props) => {
const isHovered = useSignal(false)
const userSubscription = useSignal<"Free" | "Pro" | undefined>()
useOnDocument("load", $(async () => {
const userSub = sessionStorage.getItem("subscription_data")
if (userSub) {
userSubscription.value = JSON.parse(userSub)
} else {
const subscription = await getUserSubscription$()
sessionStorage.setItem("subscription_data", JSON.stringify(subscription))
userSubscription.value = subscription
}
}))
return (
<div class="flex flex-col gap-6 w-full py-4">
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
{userSubscription.value ? (
<>
{userSubscription.value == "Pro" ? (
<div class="w-full animate-fade-in opacity-0 transition-all duration-200 ease-in" >
<Link href="/machine" class="w-full border-gray-400/70 dark:border-gray-700/70 hover:ring-2 hover:ring-[#8f8f8f] dark:hover:ring-[#707070] outline-none group transition-all duration-200 border-[2px] h-14 rounded-xl px-4 gap-2 flex items-center justify-between overflow-hidden bg-white dark:bg-black hover:bg-gray-300/70 dark:hover:bg-gray-700/70 disabled:opacity-50">
<div class="py-2 w-2/3 flex truncate group">
<div class="flex items-center justify-between group gap-2">
<div class="flex items-center w-auto gap-2 overflow-hidden">
<div class="flex items-center gap-2">
<div class="w-auto select-none text-nowrap font-medium transition-colors duration-200 ease-out group-hover:text-black text-black/80 dark:group-hover:text-white dark:text-white/80">Steam Machine</div>
</div>
</div>
{/* <div class="select-none flex gap-1 items-center overflow-hidden rounded-md px-2 py-1 text-xs font-medium transition-colors duration-200 ease-out" > */}
<div class={cn("select-none overflow-hidden flex gap-1 uppercase items-center rounded-md px-2 py-1 text-xs font-medium transition-colors duration-200 ease-out", userSubscription.value === "Pro" ? "bg-[#0CCE6B]/30 text-[#0CCE6B] group-hover:bg-[#0CCE6B]/40" : " bg-[#EE0048]/30 text-[#EE0048] hover:bg-[#EE0048]/40")}>
<div class={cn("size-2 rounded-full", userSubscription.value == "Pro" ? "bg-[#0CCE6B]" : "bg-[#EE0048]")} />
<span>{userSubscription.value == "Pro" ? "Online" : "Error"}</span>
</div>
</div>
</div>
<div
style={{
"--cutout-avatar-percentage-visible": 0.2,
"--head-margin-percentage": 0.1,
"--size": "3rem"
}}
class="relative h-full flex w-[20%] justify-end">
<img draggable={false} alt="game" width={256} height={256} src="/images/steam.png" class="h-12 shadow-lg shadow-gray-900 ring-gray-400/70 ring-1 bg-black w-12 translate-y-4 rotate-[14deg] rounded-lg object-cover transition-transform sm:h-16 sm:w-16 group-hover:scale-110" />
</div>
</Link>
</div>
) : (
<div class="w-full animate-fade-in opacity-0 transition-all duration-200 ease-in relative">
<MotionComponent
client:load
as="span"
initial={{ x: 80, y: 0, rotate: 0 }}
animate={{
x: isHovered.value ? 5 : 80,
y: isHovered.value ? -20 : 0,
rotate: isHovered.value ? 720 : 0
}}
transition={{
type: "spring",
stiffness: 200,
damping: 15,
}}
class="absolute text-[#99CCFF] flex items-center justify-center z-[1]">
<svg height="16" class="size-10" stroke-linejoin="round" viewBox="0 0 16 16" width="16" color="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.49999 0H6.49999L6.22628 1.45975C6.1916 1.64472 6.05544 1.79299 5.87755 1.85441C5.6298 1.93996 5.38883 2.04007 5.15568 2.15371C4.98644 2.2362 4.78522 2.22767 4.62984 2.12136L3.40379 1.28249L1.28247 3.40381L2.12135 4.62986C2.22766 4.78524 2.23619 4.98646 2.1537 5.15569C2.04005 5.38885 1.93995 5.62981 1.8544 5.87756C1.79297 6.05545 1.6447 6.19162 1.45973 6.2263L0 6.5V9.5L1.45973 9.7737C1.6447 9.80838 1.79297 9.94455 1.8544 10.1224C1.93995 10.3702 2.04006 10.6112 2.1537 10.8443C2.23619 11.0136 2.22767 11.2148 2.12136 11.3702L1.28249 12.5962L3.40381 14.7175L4.62985 13.8786C4.78523 13.7723 4.98645 13.7638 5.15569 13.8463C5.38884 13.9599 5.6298 14.06 5.87755 14.1456C6.05544 14.207 6.1916 14.3553 6.22628 14.5403L6.49999 16H9.49999L9.77369 14.5403C9.80837 14.3553 9.94454 14.207 10.1224 14.1456C10.3702 14.06 10.6111 13.9599 10.8443 13.8463C11.0135 13.7638 11.2147 13.7723 11.3701 13.8786L12.5962 14.7175L14.7175 12.5962L13.8786 11.3701C13.7723 11.2148 13.7638 11.0135 13.8463 10.8443C13.9599 10.6112 14.06 10.3702 14.1456 10.1224C14.207 9.94455 14.3553 9.80839 14.5402 9.7737L16 9.5V6.5L14.5402 6.2263C14.3553 6.19161 14.207 6.05545 14.1456 5.87756C14.06 5.62981 13.9599 5.38885 13.8463 5.1557C13.7638 4.98647 13.7723 4.78525 13.8786 4.62987L14.7175 3.40381L12.5962 1.28249L11.3701 2.12137C11.2148 2.22768 11.0135 2.2362 10.8443 2.15371C10.6111 2.04007 10.3702 1.93996 10.1224 1.85441C9.94454 1.79299 9.80837 1.64472 9.77369 1.45974L9.49999 0ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="currentColor"></path>
</svg>
</MotionComponent>
<MotionComponent
as="span"
client:load
initial={{ x: 80, y: 0, rotate: 0 }}
animate={{
x: isHovered.value ? 100 : 20,
y: isHovered.value ? 35 : 0,
rotate: isHovered.value ? 720 : 0
}}
transition={{
type: "spring",
stiffness: 200,
damping: 15,
}}
class="absolute text-[#99CCFF] flex items-center justify-center z-[1]">
<svg height="16" class="size-10" stroke-linejoin="round" viewBox="0 0 16 16" width="16" color="currentColor">
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.49999 0H6.49999L6.22628 1.45975C6.1916 1.64472 6.05544 1.79299 5.87755 1.85441C5.6298 1.93996 5.38883 2.04007 5.15568 2.15371C4.98644 2.2362 4.78522 2.22767 4.62984 2.12136L3.40379 1.28249L1.28247 3.40381L2.12135 4.62986C2.22766 4.78524 2.23619 4.98646 2.1537 5.15569C2.04005 5.38885 1.93995 5.62981 1.8544 5.87756C1.79297 6.05545 1.6447 6.19162 1.45973 6.2263L0 6.5V9.5L1.45973 9.7737C1.6447 9.80838 1.79297 9.94455 1.8544 10.1224C1.93995 10.3702 2.04006 10.6112 2.1537 10.8443C2.23619 11.0136 2.22767 11.2148 2.12136 11.3702L1.28249 12.5962L3.40381 14.7175L4.62985 13.8786C4.78523 13.7723 4.98645 13.7638 5.15569 13.8463C5.38884 13.9599 5.6298 14.06 5.87755 14.1456C6.05544 14.207 6.1916 14.3553 6.22628 14.5403L6.49999 16H9.49999L9.77369 14.5403C9.80837 14.3553 9.94454 14.207 10.1224 14.1456C10.3702 14.06 10.6111 13.9599 10.8443 13.8463C11.0135 13.7638 11.2147 13.7723 11.3701 13.8786L12.5962 14.7175L14.7175 12.5962L13.8786 11.3701C13.7723 11.2148 13.7638 11.0135 13.8463 10.8443C13.9599 10.6112 14.06 10.3702 14.1456 10.1224C14.207 9.94455 14.3553 9.80839 14.5402 9.7737L16 9.5V6.5L14.5402 6.2263C14.3553 6.19161 14.207 6.05545 14.1456 5.87756C14.06 5.62981 13.9599 5.38885 13.8463 5.1557C13.7638 4.98647 13.7723 4.78525 13.8786 4.62987L14.7175 3.40381L12.5962 1.28249L11.3701 2.12137C11.2148 2.22768 11.0135 2.2362 10.8443 2.15371C10.6111 2.04007 10.3702 1.93996 10.1224 1.85441C9.94454 1.79299 9.80837 1.64472 9.77369 1.45974L9.49999 0ZM8 11C9.65685 11 11 9.65685 11 8C11 6.34315 9.65685 5 8 5C6.34315 5 5 6.34315 5 8C5 9.65685 6.34315 11 8 11Z" fill="currentColor"></path>
</svg>
</MotionComponent>
<div class="hover:border-[#99CCFF]/70 border-gray-300 dark:border-gray-700 bg-white dark:bg-black z-[5] relative w-full transition-all duration-300 border-[2px] h-14 rounded-xl px-4 gap-2 flex items-center justify-between overflow-hidden outline-none disabled:opacity-50">
<span class="p-2 pl-0 text-gray-800/70 dark:text-gray-200/70 leading-none shrink truncate flex text-start items-center gap-2">
<a
onMouseEnter$={() => isHovered.value = true}
onMouseLeave$={() => isHovered.value = false}
href="https://polar.sh/nestri" class="dark:text-white text-black border-b border-[#99CCFF] py-0.5">Upgrade to Pro</a>&nbsp;to get a machine
</span>
</div>
</div>
)}
<button class="w-full animate-fade-in opacity-0 transition-all duration-200 ease-in">
<div class="border-gray-400/70 w-full dark:border-gray-700/70 transition-all border-dashed duration-200 border-[2px] h-14 rounded-xl px-4 gap-2 flex items-center justify-between overflow-hidden outline-none disabled:opacity-50">
<span class="p-2 pl-0 text-gray-600/70 dark:text-gray-400/70 leading-none shrink truncate flex text-start items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0 size-5" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M11.505 2h-1.501c-3.281 0-4.921 0-6.084.814a4.5 4.5 0 0 0-1.106 1.105C2 5.08 2 6.72 2 10s0 4.919.814 6.081a4.5 4.5 0 0 0 1.106 1.105C5.083 18 6.723 18 10.004 18h4.002c3.28 0 4.921 0 6.084-.814a4.5 4.5 0 0 0 1.105-1.105c.63-.897.772-2.08.805-4.081m-8-6h4m0 0h4m-4 0V2m0 4v4m-7 5h2m-1 3v4m-4 0h8" color="currentColor" /></svg>
Add your machine
<div class="select-none text-[#ff990a] bg-[#ff990a]/30 h-max uppercase overflow-hidden rounded-md px-2 py-1 text-xs transition-colors duration-200 ease-out font-semibold font-title">
<span>Soon</span>
</div>
</span>
</div>
</button>
</>
) :
new Array(2).fill(0).map((_, key) => (
<div class="w-full animate-pulse" key={`skeleton-machine-${key}`}>
<div class="rounded-xl bg-gray-200 dark:bg-gray-800 h-14 w-full" />
</div>
))
}
</div>
</div >
)
})

View File

@@ -0,0 +1,393 @@
import { cn } from "../design";
import Avatar from "../avatar"
import type Nestri from "@nestri/sdk"
import { MotionComponent } from "../react";
import { Dropdown, Modal } from '@qwik-ui/headless';
import { useLocation, useNavigate } from "@builder.io/qwik-city";
import { disablePageScroll, enablePageScroll } from '@fluejs/noscroll';
import { $, component$, type QRL, useOnDocument, useSignal } from "@builder.io/qwik";
type Props = {
getUserProfile$: QRL<() => Promise<Nestri.Users.UserRetrieveResponse.Data | undefined>>
}
export const HomeNavBar = component$(({ getUserProfile$ }: Props) => {
const nav = useNavigate()
const inviteEmail = useSignal('');
const location = useLocation().url
const inviteName = useSignal('');
const isHolding = useSignal(false);
const newTeamName = useSignal('');
const isNewTeam = useSignal(false);
const hasScrolled = useSignal(false);
const isNewMember = useSignal(false);
const showInviteSuccess = useSignal(false);
const userProfile = useSignal<Nestri.Users.UserRetrieveResponse.Data | undefined>()
const onDialogOpen = $((open: boolean) => {
if (open) {
disablePageScroll()
} else {
enablePageScroll()
}
})
const handlePointerDown = $(() => {
isHolding.value = true
});
const handlePointerUp = $(() => {
isHolding.value = false
});
const handleAddTeam = $((e: any) => {
e.preventDefault();
if (newTeamName.value.trim()) {
// teams.value = [...teams.value, { name: newTeamName.value.trim() }];
// selectedTeam.value = newTeamName.value.trim()
newTeamName.value = '';
isNewTeam.value = false;
}
});
const handleInvite = $((e: any) => {
e.preventDefault();
if (inviteName.value && inviteEmail.value) {
// Here you would typically make an API call to send the invitation
console.log('Sending invite to:', { name: inviteName.value, email: inviteEmail.value });
inviteName.value = '';
inviteEmail.value = '';
isNewMember.value = false;
showInviteSuccess.value = true;
setTimeout(() => {
showInviteSuccess.value = false;
}, 3000);
}
});
useOnDocument(
'scroll',
$(() => {
hasScrolled.value = window.scrollY > 0;
})
);
const handleLogout = $(async() => {
await nav(`/auth/logout`)
});
const handleLogoutAnimationComplete = $(() => {
if (isHolding.value) {
console.log("fucking hell")
// isDeleting.value = true;
// Reset the holding state
isHolding.value = false;
handleLogout();
}
});
useOnDocument("load", $(async () => {
const currentProfile = await getUserProfile$()
userProfile.value = currentProfile
}))
return (
<>
<nav class={cn("fixed w-screen justify-between top-0 z-50 px-2 sm:px-6 text-xs sm:text-sm leading-[1] text-gray-950/70 dark:text-gray-50/70 h-[66px] before:backdrop-blur-[15px] before:absolute before:-z-[1] before:top-0 before:left-0 before:w-full before:h-full flex items-center", hasScrolled.value && "shadow-[0_2px_20px_1px] shadow-gray-300 dark:shadow-gray-700")} >
<div class="flex flex-row justify-center relative items-center top-0 bottom-0">
<div class="flex-shrink-0 gap-2 flex justify-center items-center">
<svg
class="size-8 "
width="100%"
height="100%"
viewBox="0 0 12.8778 9.7377253"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg">
<path
d="m 2.093439,1.7855532 h 8.690922 V 2.2639978 H 2.093439 Z m 0,2.8440874 h 8.690922 V 5.1080848 H 2.093439 Z m 0,2.8440866 h 8.690922 V 7.952172 H 2.093439 Z"
style="font-size:12px;fill:#ff4f01;fill-opacity:1;fill-rule:evenodd;stroke:#ff4f01;stroke-width:1.66201;stroke-linecap:round;stroke-dasharray:none;stroke-opacity:1" />
</svg>
</div>
<div class="relative z-[5] items-center flex">
<hr class="dark:bg-gray-700/70 bg-gray-400/70 w-0.5 rounded-md mx-3 rotate-[16deg] h-7 border-none" />
{userProfile.value ? (
<Dropdown.Root class="animate-fade-in opacity-0 transition-all duration-200 ease-in" onOpenChange$={onDialogOpen}>
<Dropdown.Trigger class="text-sm [&>svg:first-child]:size-5 rounded-full h-8 focus:bg-gray-300/70 dark:focus:bg-gray-700/70 focus:ring-[#8f8f8f] dark:focus:ring-[#707070] focus:ring-2 outline-none dark:text-gray-400 text-gray-600 gap-2 px-3 cursor-pointer inline-flex transition-all duration-150 items-center hover:bg-gray-300/70 dark:hover:bg-gray-700/70 ">
<Avatar name={`${userProfile.value.username}'s Games`} />
<span class="truncate shrink max-w-[20ch]">{`${userProfile.value.username}'s Games`}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="32" height="32" viewBox="0 0 256 256"><path fill="currentColor" d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" /></svg>
</Dropdown.Trigger>
<Dropdown.Popover
class="bg-[hsla(0,0%,100%,.5)] dark:bg-[hsla(0,0%,100%,.026)] min-w-[160px] max-w-[240px] backdrop-blur-md rounded-lg py-1 px-2 border border-[#e8e8e8] dark:border-[#2e2e2e] [box-shadow:0_8px_30px_rgba(0,0,0,.12)]">
<Dropdown.RadioGroup class="w-full flex overflow-hidden flex-col gap-1 [&_*]:w-full [&_[data-checked]]:bg-[rgba(0,0,0,.071)] dark:[&_[data-checked]]:bg-[hsla(0,0%,100%,.077)] [&_[data-checked]]:rounded-md [&_[data-checked]]:text-[#171717] [&_[data-checked]_svg]:block cursor-pointer [&_[data-highlighted]]:text-[#171717] dark:[&_[data-checked]]:text-[#ededed] dark:[&_[data-highlighted]]:text-[#ededed] [&_[data-highlighted]]:bg-[rgba(0,0,0,.071)] dark:[&_[data-highlighted]]:bg-[hsla(0,0%,100%,.077)] [&_[data-highlighted]]:rounded-md">
<Dropdown.RadioItem
value={`${userProfile.value.username}'s Games`}
class="leading-none text-sm items-center flex px-2 h-8 rounded-md outline-none relative select-none w-full"
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate [&>svg]:size-5 text-[#6f6f6f] dark:text-[#a0a0a0]">
<Avatar class="flex-shrink-0 rounded-full" name={`${userProfile.value.username}'s Games`} />
{`${userProfile.value.username}'s Games`}
</span>
<span class="py-1 px-1 text-primary-500 [&>svg]:size-5 [&>svg]:hidden !w-max" >
<svg xmlns="http://www.w3.org/2000/svg" class="" viewBox="0 0 24 24"><path fill="currentColor" d="m10 13.6l5.9-5.9q.275-.275.7-.275t.7.275t.275.7t-.275.7l-6.6 6.6q-.3.3-.7.3t-.7-.3l-2.6-2.6q-.275-.275-.275-.7t.275-.7t.7-.275t.7.275z" /></svg>
</span>
</Dropdown.RadioItem>
</Dropdown.RadioGroup>
<Dropdown.Separator class="w-full dark:bg-[#2e2e2e] bg-[#e8e8e8] border-0 h-[1px] my-1" />
<Dropdown.Group class="flex flex-col gap-1 w-full">
<Dropdown.Item
onClick$={() => isNewTeam.value = true}
class={cn("leading-none w-full text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative", "opacity-50 pointer-events-none !cursor-not-allowed")}
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 5v14m-7-7h14" /></svg>
New Team
</span>
</Dropdown.Item>
<Dropdown.Item
// onClick$={() => isNewMember.value = true}
class={cn("leading-none w-full text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative", "opacity-50 pointer-events-none !cursor-not-allowed")}
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7a4 4 0 1 0 8 0a4 4 0 0 0-8 0m8 12h6m-3-3v6M6 21v-2a4 4 0 0 1 4-4h4" /></svg>
Send an invite
</span>
</Dropdown.Item>
<button
onPointerDown$={handlePointerDown}
onPointerUp$={handlePointerUp}
onPointerLeave$={handlePointerUp}
onKeyDown$={(e) => e.key === "Enter" && handlePointerDown()}
onKeyUp$={(e) => e.key === "Enter" && handlePointerUp()}
disabled={true}
class={cn("leading-none relative overflow-hidden transition-all duration-200 text-sm group items-center text-red-500 [&>*]:w-full [&>qwik-react]:absolute [&>qwik-react]:h-full [&>qwik-react]:left-0 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none select-none w-full", "opacity-50 pointer-events-none !cursor-not-allowed")}>
<MotionComponent
client:load
class="absolute left-0 top-0 bottom-0 bg-red-500 opacity-50 w-full h-full rounded-md"
initial={{ scaleX: 0 }}
animate={{
scaleX: isHolding.value ? 1 : 0
}}
style={{
transformOrigin: 'left',
}}
transition={{
duration: isHolding.value ? 2 : 0.5,
ease: "linear"
}}
/>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m19.5 5.5l-.402 6.506M4.5 5.5l.605 10.025c.154 2.567.232 3.85.874 4.774c.317.456.726.842 1.2 1.131c.671.41 1.502.533 2.821.57m10-7l-7 7m7 0l-7-7M3 5.5h18m-4.944 0l-.683-1.408c-.453-.936-.68-1.403-1.071-1.695a2 2 0 0 0-.275-.172C13.594 2 13.074 2 12.035 2c-1.066 0-1.599 0-2.04.234a2 2 0 0 0-.278.18c-.395.303-.616.788-1.058 1.757L8.053 5.5" color="currentColor" /></svg>
<span class="group-hover:hidden">Delete Team</span>
<span class="hidden group-hover:block">Hold to delete</span>
</span>
</button>
</Dropdown.Group>
</Dropdown.Popover>
</Dropdown.Root>) : (<div class="h-6 w-40 rounded-md bg-gray-200 dark:bg-gray-800 animate-pulse" />)}
{location.pathname == "/machine" &&
(<>
<hr class="dark:bg-gray-700/70 bg-gray-400/70 w-0.5 rounded-md mx-3 rotate-[16deg] h-7 border-none" />
{userProfile.value ? (
<div class="text-sm [&>svg:first-child]:size-5 rounded-full h-8 focus:bg-gray-300/70 dark:focus:bg-gray-700/70 focus:ring-[#8f8f8f] dark:focus:ring-[#707070] focus:ring-2 outline-none dark:text-gray-400 text-gray-600 gap-2 px-3 cursor-pointer inline-flex transition-all duration-150 items-center hover:bg-gray-300/70 dark:hover:bg-gray-700/70 ">
<span class="truncate shrink max-w-[20ch]">{`Steam Machine`}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4" width="32" height="32" viewBox="0 0 256 256"><path fill="currentColor" d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" /></svg>
</div>
) : (<div class="h-6 w-40 rounded-md bg-gray-200 dark:bg-gray-800 animate-pulse" />)}
</>
)
}
</div>
</div>
<div class="gap-4 flex flex-row justify-center h-full items-center">
{userProfile.value ?
(<Dropdown.Root class="animate-fade-in opacity-0 transition-all duration-200 ease-in" onOpenChange$={onDialogOpen}>
<Dropdown.Trigger class="focus:bg-gray-300/70 dark:focus:bg-gray-700/70 focus:ring-[#8f8f8f] dark:focus:ring-[#707070] text-gray-600 dark:text-gray-400 [&>svg:first-child]:size-5 text-sm focus:ring-2 outline-none rounded-full transition-all flex items-center duration-150 select-none cursor-pointer hover:bg-gray-300/70 dark:hover:bg-gray-700/70 gap-1 px-3 h-8" >
{userProfile.value.avatarUrl ? (<img src={userProfile.value.avatarUrl} height={20} width={20} class="size-6 rounded-full" alt="Avatar" />) : (<Avatar name={`${userProfile.value.username}#${userProfile.value.discriminator}`} />)}
<span class="truncate shrink max-w-[20ch] sm:flex hidden">{`${userProfile.value.username}#${userProfile.value.discriminator}`}</span>
<svg xmlns="http://www.w3.org/2000/svg" class="size-4 sm:block hidden" width="32" height="32" viewBox="0 0 256 256"><path fill="currentColor" d="M72.61 83.06a8 8 0 0 1 1.73-8.72l48-48a8 8 0 0 1 11.32 0l48 48A8 8 0 0 1 176 88H80a8 8 0 0 1-7.39-4.94M176 168H80a8 8 0 0 0-5.66 13.66l48 48a8 8 0 0 0 11.32 0l48-48A8 8 0 0 0 176 168" /></svg>
</Dropdown.Trigger>
<Dropdown.Popover
class="bg-[hsla(0,0%,100%,.5)] dark:bg-[hsla(0,0%,100%,.026)] min-w-[160px] max-w-[240px] backdrop-blur-md rounded-lg py-1 px-2 border border-[#e8e8e8] dark:border-[#2e2e2e] [box-shadow:0_8px_30px_rgba(0,0,0,.12)]">
<Dropdown.Group class="flex flex-col gap-1">
<Dropdown.Item
onClick$={() => window.location.href = "mailto:feedback@nestri.io"}
class="leading-none text-sm items-center text-[#6f6f6f] dark:text-[#a0a0a0] hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative select-none "
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="currentColor" d="M22 8.5a6.5 6.5 0 0 0-11.626-3.993A9.5 9.5 0 0 1 19.5 14q0 .165-.006.33l.333.088a1.3 1.3 0 0 0 1.592-1.591l-.128-.476c-.103-.385-.04-.791.125-1.153A6.5 6.5 0 0 0 22 8.5" /><path fill="currentColor" fill-rule="evenodd" d="M18 14a8 8 0 0 1-11.45 7.22a1.67 1.67 0 0 0-1.15-.13l-1.227.329a1.3 1.3 0 0 1-1.591-1.592L2.91 18.6a1.67 1.67 0 0 0-.13-1.15A8 8 0 1 1 18 14M6.5 15a1 1 0 1 0 0-2a1 1 0 0 0 0 2m3.5 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2m3.5 0a1 1 0 1 0 0-2a1 1 0 0 0 0 2" clip-rule="evenodd" /></svg>
Send Feedback
</span>
</Dropdown.Item>
{/* <button
onPointerDown$={handlePointerDown}
onPointerUp$={handlePointerUp}
onPointerLeave$={handlePointerUp}
onKeyDown$={(e) => e.key === "Enter" && handlePointerDown()}
onKeyUp$={(e) => e.key === "Enter" && handlePointerUp()}
class="leading-none relative overflow-hidden transition-all duration-200 text-sm group items-center text-red-500 [&>*]:w-full [&>qwik-react]:absolute [&>qwik-react]:h-full [&>qwik-react]:left-0 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none select-none w-full">
<MotionComponent
client:load
class="absolute left-0 top-0 bottom-0 bg-red-500 opacity-50 w-full h-full rounded-md"
initial={{ scaleX: 0 }}
animate={{
scaleX: isHolding.value ? 1 : 0
}}
style={{
transformOrigin: 'left',
}}
transition={{
duration: isHolding.value ? 2 : 0.5,
ease: "linear"
}}
onAnimationComplete$={handleLogoutAnimationComplete}
/>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><g fill="none"><path fill="currentColor" fill-rule="evenodd" d="M10.138 1.815A3 3 0 0 1 14 4.688v14.624a3 3 0 0 1-3.862 2.873l-6-1.8A3 3 0 0 1 2 17.512V6.488a3 3 0 0 1 2.138-2.873zM15 4a1 1 0 0 1 1-1h3a3 3 0 0 1 3 3v1a1 1 0 1 1-2 0V6a1 1 0 0 0-1-1h-3a1 1 0 0 1-1-1m6 12a1 1 0 0 1 1 1v1a3 3 0 0 1-3 3h-3a1 1 0 1 1 0-2h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1M9 11a1 1 0 1 0 0 2h.001a1 1 0 1 0 0-2z" clip-rule="evenodd" /><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12h5m0 0l-2-2m2 2l-2 2" /></g></svg>
<span class="group-hover:hidden">Log out</span>
<span class="hidden group-hover:block">Hold to logout</span>
</span>
</button> */}
<button
onPointerDown$={handlePointerDown}
onPointerUp$={handlePointerUp}
onPointerLeave$={handlePointerUp}
onKeyDown$={(e) => e.key === "Enter" && handlePointerDown()}
onKeyUp$={(e) => e.key === "Enter" && handlePointerUp()}
class={cn("leading-none relative overflow-hidden transition-all duration-200 text-sm group items-center text-red-500 [&>*]:w-full [&>qwik-react]:absolute [&>qwik-react]:h-full [&>qwik-react]:left-0 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none select-none w-full")}>
<MotionComponent
client:load
class="absolute left-0 top-0 bottom-0 bg-red-500 opacity-50 w-full h-full rounded-md"
initial={{ scaleX: 0 }}
animate={{
scaleX: isHolding.value ? 1 : 0
}}
style={{
transformOrigin: 'left',
}}
transition={{
duration: isHolding.value ? 2 : 0.5,
ease: "linear"
}}
onAnimationComplete$={handleLogoutAnimationComplete}
/>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><g fill="none"><path fill="currentColor" fill-rule="evenodd" d="M10.138 1.815A3 3 0 0 1 14 4.688v14.624a3 3 0 0 1-3.862 2.873l-6-1.8A3 3 0 0 1 2 17.512V6.488a3 3 0 0 1 2.138-2.873zM15 4a1 1 0 0 1 1-1h3a3 3 0 0 1 3 3v1a1 1 0 1 1-2 0V6a1 1 0 0 0-1-1h-3a1 1 0 0 1-1-1m6 12a1 1 0 0 1 1 1v1a3 3 0 0 1-3 3h-3a1 1 0 1 1 0-2h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1M9 11a1 1 0 1 0 0 2h.001a1 1 0 1 0 0-2z" clip-rule="evenodd" /><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 12h5m0 0l-2-2m2 2l-2 2" /></g></svg>
<span class="group-hover:hidden">Log out</span>
<span class="hidden group-hover:block">Hold to logout</span>
</span>
</button>
</Dropdown.Group>
{/* <Dropdown.Separator class="w-full dark:bg-[#2e2e2e] bg-[#e8e8e8] border-0 h-[1px] my-1" />
<Dropdown.Group class="flex flex-col gap-1">
<Dropdown.Item
class="leading-none transition-all duration-200 text-sm group items-center text-red-500 hover:text-[#171717] dark:hover:text-[#ededed] hover:bg-[rgba(0,0,0,.071)] dark:hover:bg-[hsla(0,0%,100%,.077)] flex px-2 gap-2 h-8 rounded-md cursor-pointer outline-none relative select-none "
>
<span class="w-full max-w-[20ch] flex items-center gap-2 truncate overflow-visible [&>svg]:size-5 ">
<svg xmlns="http://www.w3.org/2000/svg" class="flex-shrink-0" viewBox="0 0 24 24"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M13 22H6.59c-1.545 0-2.774-.752-3.877-1.803c-2.26-2.153 1.45-3.873 2.865-4.715a10.67 10.67 0 0 1 7.922-1.187m3-7.795a4.5 4.5 0 1 1-9 0a4.5 4.5 0 0 1 9 0M16 22l3-3m0 0l3-3m-3 3l-3-3m3 3l3 3" color="currentColor" /></svg>
<span class="group-hover:hidden">Leave Team</span>
<span class="hidden group-hover:block">Hold to leave</span>
</span>
</Dropdown.Item>
</Dropdown.Group> */}
</Dropdown.Popover>
</Dropdown.Root>) :
(<div class="flex gap-2 justify-center items-center animate-pulse " >
<div class="h-6 w-20 rounded-md bg-gray-200 dark:bg-gray-800 right-4" />
<div class="size-8 rounded-full bg-gray-200 dark:bg-gray-800 right-4" />
</div>)
}
</div>
</nav>
<Modal.Root bind:show={isNewTeam} class="w-full">
<Modal.Panel
class="dark:bg-black bg-white [box-shadow:0_8px_30px_rgba(0,0,0,.12)]
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] max-h-[75vh] rounded-xl
backdrop-blur-md modal max-w-[400px] w-full border dark:border-gray-800 border-gray-200">
<form preventdefault:submit onSubmit$={handleAddTeam}>
<main class="size-full flex flex-col relative py-4 px-5">
<div class="dark:text-white text-black">
<h3 class="font-semibold text-2xl tracking-tight mb-1 font-title">Create a team</h3>
<div class="text-sm dark:text-gray-200/70 text-gray-800/70" >
Continue to start playing with on Pro with increased usage, additional security features, and support
</div>
</div>
<div class="mt-3 flex flex-col gap-3" >
<div>
<label for="name" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Team Name
</label>
<input
//@ts-expect-error
onInput$={(e) => newTeamName.value = e.target!.value}
required value={newTeamName.value} id="name" type="text" placeholder="Enter team name" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
</div>
</main>
<footer class="dark:text-gray-200/70 text-gray-800/70 dark:bg-gray-900 bg-gray-100 ring-1 ring-gray-200 dark:ring-gray-800 select-none flex gap-2 items-center justify-between w-full bottom-0 left-0 py-3 px-5 text-sm leading-none">
<Modal.Close class="rounded-lg [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] py-3 px-4 hover:bg-gray-200 dark:hover:bg-gray-800 flex items-center justify-center">
Cancel
</Modal.Close>
<button
type="submit"
class="flex items-center justify-center gap-2 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-200 dark:bg-gray-800 py-3 px-4 hover:bg-gray-300 dark:hover:bg-gray-700 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)]" >
Continue
</button>
</footer>
</form>
</Modal.Panel>
</Modal.Root >
<Modal.Root bind:show={isNewMember} class="w-full">
<Modal.Panel
class="dark:bg-black bg-white [box-shadow:0_8px_30px_rgba(0,0,0,.12)]
dark:backdrop:bg-[#0009] backdrop:bg-[#b3b5b799] backdrop:backdrop-grayscale-[.3] max-h-[75vh] rounded-xl
backdrop-blur-md modal max-w-[400px] w-full border dark:border-gray-800 border-gray-200">
<form preventdefault:submit onSubmit$={handleInvite}>
<main class="size-full flex flex-col relative py-4 px-5">
<div class="dark:text-white text-black">
<h3 class="font-semibold text-2xl tracking-tight mb-1 font-title">Send an invite</h3>
<div class="text-sm dark:text-gray-200/70 text-gray-800/70" >
Friends will receive an email allowing them to join this team
</div>
</div>
<div class="mt-3 flex flex-col gap-3" >
<div>
<label for="name" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Name
</label>
<input
value={inviteName.value}
//@ts-expect-error
onInput$={(e) => inviteName.value = e.target!.value}
id="name" type="text" placeholder="Jane Doe" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full bg-transparent px-2 py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
<div>
<label for="email" class="text-sm dark:text-gray-200 text-gray-800 pb-2 pt-1" >
Email
</label>
<input
value={inviteEmail.value}
//@ts-expect-error
onInput$={(e) => inviteEmail.value = e.target!.value}
id="email" type="email" placeholder="jane@doe.com" class="[transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] w-full px-2 bg-transparent py-3 h-10 border text-black dark:text-white dark:border-gray-700/70 border-gray-300/70 rounded-md text-sm outline-none leading-none focus:ring-gray-300 dark:focus:ring-gray-700 focus:ring-2" />
</div>
</div>
</main>
<footer class="dark:text-gray-200/70 text-gray-800/70 dark:bg-gray-900 bg-gray-100 ring-1 ring-gray-200 dark:ring-gray-800 select-none flex gap-2 items-center justify-between w-full bottom-0 left-0 py-3 px-5 text-sm leading-none">
<Modal.Close class="rounded-lg [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)] py-3 px-4 hover:bg-gray-200 dark:hover:bg-gray-800 flex items-center justify-center">
Cancel
</Modal.Close>
<button type="submit" class="flex items-center justify-center gap-2 border-2 border-gray-300 dark:border-gray-700 rounded-lg bg-gray-200 dark:bg-gray-800 py-3 px-4 hover:bg-gray-300 dark:hover:bg-gray-700 [transition:all_0.3s_cubic-bezier(0.4,0,0.2,1)]" >
Send an invite
</button>
</footer>
</form>
</Modal.Panel>
</Modal.Root>
</>
)
})

View File

@@ -2,10 +2,13 @@ export * from "./nav-progress"
export * from "./nav-bar"
export * from "./fonts"
export * from "./input"
export * from "./home-nav-bar"
export * from "./home/nav-bar"
export * from "./home/machines"
export * from "./home/games"
export * from "./home/friends"
export * from "./game-card"
export * from "./tooltip"
export * from "./footer"
export * from "./react/footer"
export * from "./card"
export * from "./router-head"
export * from "./team-counter"

View File

@@ -94,7 +94,7 @@ export const HModalPanel = component$((props: PropsOf<'dialog'>) => {
const handleKeyDown$ = $((e: KeyboardEvent) => {
if (e.key === 'Escape') {
context.showSig.value = false;
// context.showSig.value = false;
e.stopPropagation();
}
});

View File

@@ -13,15 +13,12 @@ const navLinks = [
},
{
name: "Login",
href: "/auth/login"
}
]
type Props = {
link?: string
}
export const NavBar = component$(({ link }: Props) => {
export const NavBar = component$(() => {
const location = useLocation()
const hasScrolled = useSignal(false);
@@ -35,24 +32,16 @@ export const NavBar = component$(({ link }: Props) => {
return (
<nav class={cn("w-full sticky top-0 z-50 text-sm font-extrabold bg-gray-100/70 dark:bg-gray-900/70 before:backdrop-blur-[15px] before:absolute before:-z-[1] before:top-0 before:left-0 before:w-full before:h-full max-w-full overflow-hidden", hasScrolled.value && "shadow-[0_2px_20px_1px] shadow-gray-300 dark:shadow-gray-700")} >
<button onClick$={() => window.location.href = link as string} class="w-full text-gray-900/70 bg-gray-400/30 dark:bg-gray-600/30 dark:text-gray-100/30 whitespace-nowrap font-mono text-sm py-3">
<div class="flex relative">
<span class="whitespace-pre marquee-animation">
Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username ·
Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username · Launching Soon · Login to reserve your username ·
</span>
</div>
</button>
<div class="px-4 mx-auto flex max-w-xl items-center border-b-2 dark:border-gray-50/50 border-gray-950/50" >
<div class="px-4 mx-auto flex max-w-[600px] items-center sm:border-b-2 dark:border-gray-600 border-gray-400" >
<Link class="outline-none focus:ring-2 py-1 px-3 -ml-3 rounded-lg focus:ring-primary-500 duration-200 transition-all" href="/" >
<h1 class="text-lg font-title" >
<h1 class="text-lg font-bricolage font-semibold" >
Nestri
</h1>
</Link>
<ul class="ml-0 -mr-4 flex font-medium m-4 flex-1 gap-1 tracking-[0.035em] items-center justify-end dark:text-primary-50/70 text-primary-950/70">
<ul class="ml-0 -mr-4 flex font-medium m-4 font-mona tracking-tight flex-1 gap-1 items-center justify-end dark:text-primary-50/70 text-primary-950/70">
{navLinks.map((linkItem, key) => (
<li key={`linkItem-${key}`}>
<Link href={linkItem.href ? linkItem.href : link} class={cn(buttonVariants.ghost({ intent: "gray", size: "sm" }), "hover:bg-gray-300/70 dark:hover:bg-gray-700/70 focus:ring-2 outline-none focus:ring-primary-500 duration-200 transition-all", location.url.pathname === linkItem.href && "bg-gray-300/70 hover:bg-gray-300/70 dark:bg-gray-700/70 dark:hover:bg-gray-700/70")}>
<Link href={linkItem.href} prefetch={linkItem.name === "Login" && false} class={cn(buttonVariants.ghost({ intent: "gray", size: "sm" }), "hover:bg-gray-300/70 dark:hover:bg-gray-700/70 focus:ring-2 outline-none focus:ring-primary-500 duration-200 transition-all", location.url.pathname === linkItem.href && "bg-gray-300/70 hover:bg-gray-300/70 dark:bg-gray-700/70 dark:hover:bg-gray-700/70")}>
{linkItem.name}
</Link>
</li>

View File

@@ -44,7 +44,7 @@ export const ReactDisplay = ({
size,
weight,
align,
className: cn("font-title font-extrabold", className)
className: cn("font-basement font-extrabold", className)
})} {...props}>
<ReactBalancer.Balancer>
{children}

View File

@@ -0,0 +1,145 @@
/** @jsxImportSource react */
// import { FooterBanner } from "../footer-banner";
import { motion } from "framer-motion"
import { ReactDisplay } from "./display"
import { qwikify$ } from "@builder.io/qwik-react";
const transition = {
type: "spring",
stiffness: 100,
damping: 15,
restDelta: 0.001,
duration: 0.01,
}
type Props = {
children?: React.ReactNode;
}
export function ReactFooter({ children }: Props) {
return (
<>
<footer className="flex justify-center flex-col items-center w-screen pt-8 sm:pb-0 pb-8 [&>*]:w-full px-3">
<section className="mx-auto flex flex-col justify-center items-center max-w-[600px] pt-20">
<motion.img
initial={{
opacity: 0,
y: 120
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
transition={{
...transition
}}
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[50px] md:w-[80px] aspect-[90/69] select-none" />
<div className="my-4 sm:mt-8 w-full flex flex-col justify-center items-center">
<ReactDisplay className="mb-4 sm:text-[5.6rem] text-[3.2rem] text-balance text-center tracking-tight leading-none" >
<motion.span
initial={{
opacity: 0,
y: 100
}}
whileInView={{
y: 0,
opacity: 1
}}
transition={{
delay: 0.1,
...transition
}}
viewport={{ once: true }}
className="inline-block" >
Your games
</motion.span>
<motion.span
initial={{
opacity: 0,
y: 80
}}
transition={{
delay: 0.2,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="inline-block" >
Your rules
</motion.span>
</ReactDisplay>
{/* <motion.p
initial={{
opacity: 0,
y: 50
}}
transition={{
delay: 0.3,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="dark:text-gray-50/70 text-gray-950/70 text-base font-normal text-center leading-tight text-balance tracking-tight sm:text-xl"
>
Nestri is an open-source cloud gaming platform that lets you play games on your own terms — invite friends to join your gaming sessions, share your game library, and take even more control by running it on your own GPU instance
</motion.p> */}
<motion.div
initial={{
opacity: 0,
y: 60
}}
transition={{
delay: 0.4,
...transition
}}
whileInView={{
y: 0,
opacity: 1
}}
viewport={{ once: true }}
className="flex items-center justify-center mt-4 w-full"
>
{children}
</motion.div>
</div>
</section>
<motion.div
initial={{ opacity: 0 }}
whileInView={{ opacity: 1 }}
transition={{
...transition,
duration: 0.8,
delay: 0.7
}}
className="w-full sm:flex z-[1] hidden pointer-events-none overflow-hidden -mt-[80px] justify-center items-center flex-col" >
<section className='my-0 bottom-0 text-[100%] max-w-[1440px] pointer-events-none w-full flex items-center translate-y-[40%] justify-center relative overflow-hidden px-2 z-10 [&_svg]:w-full [&_svg]:max-w-[1440px] [&_svg]:h-full [&_svg]:opacity-70' >
<svg viewBox="0 0 498.05 70.508" xmlns="http://www.w3.org/2000/svg" height={157} width={695} >
<g strokeLinecap="round" fillRule="evenodd" fontSize="9pt" stroke="currentColor" strokeWidth="0.25mm" fill="currentColor">
<path
fill="url(#paint1)"
pathLength="1"
stroke="url(#paint1)"
d="M 261.23 41.65 L 212.402 41.65 Q 195.313 41.65 195.313 27.002 L 195.313 14.795 A 17.814 17.814 0 0 1 196.311 8.57 Q 199.443 0.146 212.402 0.146 L 283.203 0.146 L 283.203 14.844 L 217.236 14.844 Q 215.337 14.844 214.945 16.383 A 3.67 3.67 0 0 0 214.844 17.285 L 214.844 24.561 Q 214.844 27.002 217.236 27.002 L 266.113 27.002 Q 283.203 27.002 283.203 41.65 L 283.203 53.857 A 17.814 17.814 0 0 1 282.205 60.083 Q 279.073 68.506 266.113 68.506 L 195.313 68.506 L 195.313 53.809 L 261.23 53.809 A 3.515 3.515 0 0 0 262.197 53.688 Q 263.672 53.265 263.672 51.367 L 263.672 44.092 A 3.515 3.515 0 0 0 263.551 43.126 Q 263.128 41.65 261.23 41.65 Z M 185.547 53.906 L 185.547 68.506 L 114.746 68.506 Q 97.656 68.506 97.656 53.857 L 97.656 14.795 A 17.814 17.814 0 0 1 98.655 8.57 Q 101.787 0.146 114.746 0.146 L 168.457 0.146 Q 185.547 0.146 185.547 14.795 L 185.547 31.885 A 17.827 17.827 0 0 1 184.544 38.124 Q 181.621 45.972 170.174 46.538 A 36.906 36.906 0 0 1 168.457 46.582 L 117.188 46.582 L 117.236 51.465 Q 117.236 53.906 119.629 53.955 L 185.547 53.906 Z M 19.531 14.795 L 19.531 68.506 L 0 68.506 L 0 0.146 L 70.801 0.146 Q 87.891 0.146 87.891 14.795 L 87.891 68.506 L 68.359 68.506 L 68.359 17.236 Q 68.359 14.795 65.967 14.795 L 19.531 14.795 Z M 449.219 68.506 L 430.176 46.533 L 400.391 46.533 L 400.391 68.506 L 380.859 68.506 L 380.859 0.146 L 451.66 0.146 A 24.602 24.602 0 0 1 458.423 0.994 Q 466.007 3.166 468.021 10.907 A 25.178 25.178 0 0 1 468.75 17.236 L 468.75 31.885 A 18.217 18.217 0 0 1 467.887 37.73 Q 465.954 43.444 459.698 45.455 A 23.245 23.245 0 0 1 454.492 46.436 L 473.633 68.506 L 449.219 68.506 Z M 292.969 0 L 371.094 0.098 L 371.094 14.795 L 341.846 14.795 L 341.846 68.506 L 322.266 68.506 L 322.217 14.795 L 292.969 14.844 L 292.969 0 Z M 478.516 0.146 L 498.047 0.146 L 498.047 68.506 L 478.516 68.506 L 478.516 0.146 Z M 400.391 14.844 L 400.391 31.885 L 446.826 31.885 Q 448.726 31.885 449.117 30.345 A 3.67 3.67 0 0 0 449.219 29.443 L 449.219 17.285 Q 449.219 14.844 446.826 14.844 L 400.391 14.844 Z M 117.188 31.836 L 163.574 31.934 Q 165.528 31.895 165.918 30.355 A 3.514 3.514 0 0 0 166.016 29.492 L 166.016 17.236 Q 166.016 15.337 164.476 14.945 A 3.67 3.67 0 0 0 163.574 14.844 L 119.629 14.795 Q 117.188 14.795 117.188 17.188 L 117.188 31.836 Z" />
</g>
<defs>
<linearGradient gradientUnits="userSpaceOnUse" id="paint1" x1="317.5" x2="314.007" y1="-51.5" y2="126">
<stop stopColor="white"></stop>
<stop offset="1" stopOpacity="0"></stop>
</linearGradient>
</defs>
</svg>
</section>
</motion.div>
</footer>
</>
);
}
export const Footer = qwikify$(ReactFooter)

View File

@@ -20,8 +20,8 @@ type Props = {
export function ReactHeroSection({ children }: Props) {
return (
<>
<section className="px-4 w-screen" >
<header className="mx-auto max-w-xl pt-20 pb-1">
<section className="px-4 w-screen pt-10 sm:pt-0" >
<header className="mx-auto flex flex-col justify-center items-center max-w-[600px] pt-20 pb-1">
<motion.img
initial={{
opacity: 0,
@@ -35,9 +35,9 @@ export function ReactHeroSection({ children }: Props) {
transition={{
...transition
}}
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[70px] md:w-[80px] aspect-[90/69]" />
<div className="my-4 sm:mt-8">
<ReactDisplay className="mb-4 sm:text-8xl text-[3.5rem] text-balance tracking-tight leading-none" >
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[50px] md:w-[80px] aspect-[90/69] select-none" />
<div className="my-4 sm:mt-8 w-full flex flex-col justify-center items-center">
<ReactDisplay className="mb-4 sm:text-[5.6rem] text-[3.2rem] text-balance text-center tracking-tight leading-none" >
<motion.span
initial={{
opacity: 0,
@@ -53,7 +53,7 @@ export function ReactHeroSection({ children }: Props) {
}}
viewport={{ once: true }}
className="inline-block" >
Your games.
Your games
</motion.span>
<motion.span
initial={{
@@ -70,7 +70,7 @@ export function ReactHeroSection({ children }: Props) {
}}
viewport={{ once: true }}
className="inline-block" >
Your rules.
Your rules
</motion.span>
</ReactDisplay>
<motion.p
@@ -87,9 +87,9 @@ export function ReactHeroSection({ children }: Props) {
opacity: 1
}}
viewport={{ once: true }}
className="dark:text-gray-50/70 text-gray-950/70 text-lg font-normal tracking-tight sm:text-xl"
className="dark:text-gray-50/70 text-gray-950/70 text-base font-normal text-center leading-tight text-balance tracking-tight sm:text-xl"
>
Nestri is an open-source cloud gaming platform that lets you play games on your own terms invite friends to join your gaming sessions, share your game library, and take even more control by hosting on your own server.
Nestri is an open-source cloud gaming platform that lets you play games on your own terms invite friends to join your gaming sessions, share your game library, and take even more control by running it on your own GPU instance
</motion.p>
<motion.div
initial={{

View File

@@ -24,7 +24,7 @@ export function ReactTitleSection({ title, description }: Props) {
return (
<>
<section className="px-4" >
<header className="overflow-hidden mx-auto max-w-xl pt-20 pb-4">
<header className="overflow-hidden mx-auto max-w-xl pt-20 pb-4 flex justify-center items-center flex-col">
<motion.img
initial={{
opacity: 0,
@@ -39,7 +39,7 @@ export function ReactTitleSection({ title, description }: Props) {
}}
viewport={{ once: true }}
src="/logo.webp" alt="Nestri Logo" height={80} width={80} draggable={false} className="w-[70px] md:w-[80px] aspect-[90/69]" />
<div className="my-4 sm:my-8">
<div className="my-4 sm:my-8 flex justify-center items-center flex-col text-center">
<ReactDisplay className="mb-4 sm:text-8xl text-[3.5rem] text-balance tracking-tight leading-none" >
<motion.span
initial={{

File diff suppressed because one or more lines are too long

View File

@@ -2,57 +2,8 @@
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
declare module "sst" {
export interface Resource {
"Api": {
"type": "sst.cloudflare.Worker"
"url": string
}
"Auth": {
"type": "sst.cloudflare.Worker"
"url": string
}
"AuthFingerprintKey": {
"type": "random.index/randomString.RandomString"
"value": string
}
"CloudflareAuthKV": {
"type": "sst.cloudflare.Kv"
}
"DiscordClientID": {
"type": "sst.sst.Secret"
"value": string
}
"DiscordClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientID": {
"type": "sst.sst.Secret"
"value": string
}
"GithubClientSecret": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAdminToken": {
"type": "sst.sst.Secret"
"value": string
}
"InstantAppId": {
"type": "sst.sst.Secret"
"value": string
}
"LoopsApiKey": {
"type": "sst.sst.Secret"
"value": string
}
"Urls": {
"api": string
"auth": string
"type": "sst.sst.Linkable"
}
}
}
export {}

View File

@@ -61,8 +61,39 @@ export default {
"Courier New",
"monospace"
],
title: [
"Bricolage Grotesque",
mona: [
"Mona Sans Variable",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",
"roboto",
"oxygen",
"ubuntu",
"cantarell",
"fira",
"sans",
"droid sans",
"helvetica neue",
"sans-serif",
],
bricolage: [
"Bricolage Grotesque Variable",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",
"roboto",
"oxygen",
"ubuntu",
"cantarell",
"fira",
"sans",
"droid sans",
"helvetica neue",
"sans-serif",
],
basement: [
"Basement Grotesque",
"Bricolage Grotesque Variable",
"-apple-system",
"blinkmacsystemfont",
"segoe ui",

View File

@@ -17,9 +17,6 @@
"isolatedModules": true,
"outDir": "tmp",
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
}
},
"files": ["./.eslintrc.cjs"],
"include": ["src", "./*.d.ts", "./*.config.ts","./*.config.js"]