mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
⭐ 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:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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, "")
|
||||
// }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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" }
|
||||
// }
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
90
packages/core/src/aws/client.ts
Normal file
90
packages/core/src/aws/client.ts
Normal 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
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
|
||||
}
|
||||
// }
|
||||
83
packages/core/src/instance/index.ts
Normal file
83
packages/core/src/instance/index.ts
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
// })
|
||||
|
||||
}
|
||||
// }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
331
packages/core/src/task/index.ts
Normal file
331
packages/core/src/task/index.ts
Normal 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
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}))
|
||||
)
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
57
packages/core/sst-env.d.ts
vendored
57
packages/core/sst-env.d.ts
vendored
@@ -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 {}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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": {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"$schema": "https://www.partykit.io/schema.json",
|
||||
"name": "nestri-party",
|
||||
"main": "src/party/index.ts",
|
||||
"compatibilityDate": "2024-12-31"
|
||||
}
|
||||
@@ -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);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
// },
|
||||
// );
|
||||
// }
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
277
packages/functions/src/api/task.ts
Normal file
277
packages/functions/src/api/task.ts
Normal 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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
38
packages/functions/src/party/authorizer.ts
Normal file
38
packages/functions/src/party/authorizer.ts
Normal 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}/*`],
|
||||
};
|
||||
});
|
||||
64
packages/functions/src/party/create.ts
Normal file
64
packages/functions/src/party/create.ts
Normal 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),
|
||||
};
|
||||
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
4
packages/functions/src/party/subscriber.ts
Normal file
4
packages/functions/src/party/subscriber.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const handler = async (event: any) => {
|
||||
console.log(event);
|
||||
return "ok";
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
})
|
||||
4
packages/functions/src/type.ts
Normal file
4
packages/functions/src/type.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum Subscription {
|
||||
Pro = "Pro",
|
||||
Free = "Free"
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
22
packages/functions/sst-env.d.ts
vendored
22
packages/functions/sst-env.d.ts
vendored
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
57
packages/input/sst-env.d.ts
vendored
57
packages/input/sst-env.d.ts
vendored
@@ -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
23
packages/maitred/go.mod
Normal 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
49
packages/maitred/go.sum
Normal 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
21
packages/maitred/main.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
27
packages/maitred/pkg/party/logger.go
Normal file
27
packages/maitred/pkg/party/logger.go
Normal 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)
|
||||
}
|
||||
129
packages/maitred/pkg/party/party.go
Normal file
129
packages/maitred/pkg/party/party.go
Normal 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)
|
||||
}
|
||||
31
packages/maitred/pkg/party/utils.go
Normal file
31
packages/maitred/pkg/party/utils.go
Normal 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())
|
||||
}
|
||||
@@ -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
|
||||
57
packages/moq/sst-env.d.ts
vendored
57
packages/moq/sst-env.d.ts
vendored
@@ -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 {}
|
||||
@@ -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));;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 />;
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -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>
|
||||
<></>
|
||||
)
|
||||
})
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
135
packages/ui/src/home/friends.tsx
Normal file
135
packages/ui/src/home/friends.tsx
Normal 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}`} <p class="hidden group-hover:block text-gray-600/70 dark:text-gray-400/70 transition-all duration-200 ease-in">{` #${user.discriminator}`}</p>
|
||||
{/* {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>
|
||||
)
|
||||
})
|
||||
102
packages/ui/src/home/games.tsx
Normal file
102
packages/ui/src/home/games.tsx
Normal 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 >
|
||||
)
|
||||
})
|
||||
131
packages/ui/src/home/machines.tsx
Normal file
131
packages/ui/src/home/machines.tsx
Normal 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> 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 >
|
||||
)
|
||||
})
|
||||
393
packages/ui/src/home/nav-bar.tsx
Normal file
393
packages/ui/src/home/nav-bar.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
145
packages/ui/src/react/footer.tsx
Normal file
145
packages/ui/src/react/footer.tsx
Normal 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)
|
||||
@@ -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={{
|
||||
|
||||
@@ -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
57
packages/ui/sst-env.d.ts
vendored
57
packages/ui/sst-env.d.ts
vendored
@@ -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 {}
|
||||
@@ -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",
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
"isolatedModules": true,
|
||||
"outDir": "tmp",
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"files": ["./.eslintrc.cjs"],
|
||||
"include": ["src", "./*.d.ts", "./*.config.ts","./*.config.js"]
|
||||
|
||||
Reference in New Issue
Block a user