refactor: change project and templates layout
This commit is contained in:
22
internals/router.go
Normal file
22
internals/router.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package internals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RouteHandler = func(http.ResponseWriter, *http.Request)
|
||||||
|
|
||||||
|
type Route struct {
|
||||||
|
Pattern string
|
||||||
|
Static bool
|
||||||
|
Handler RouteHandler
|
||||||
|
Page templ.Component
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterAllRoutes(routes []Route, s *http.ServeMux) {
|
||||||
|
for _, r := range routes {
|
||||||
|
s.HandleFunc(r.Pattern, r.Handler)
|
||||||
|
}
|
||||||
|
}
|
||||||
2
main.go
2
main.go
@@ -26,7 +26,7 @@ func main() {
|
|||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
routes.RegisterAllRoutes(routes.ROUTES, mux)
|
internals.RegisterAllRoutes(routes.ROUTES, mux)
|
||||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.URL.Path != "/" {
|
if r.URL.Path != "/" {
|
||||||
logger.Printf("Handling file server request. path=%s", r.URL.Path)
|
logger.Printf("Handling file server request. path=%s", r.URL.Path)
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"fmt"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"net/url"
|
|
||||||
|
|
||||||
"extrovert/layouts"
|
|
||||||
"extrovert/internals"
|
|
||||||
"slices"
|
|
||||||
"strings"
|
|
||||||
"log"
|
|
||||||
)
|
|
||||||
|
|
||||||
const MASTODON_APP_NAME = "project-extrovert-v1"
|
|
||||||
const MASTODON_COOKIE_NAME = "mastodon-cookie"
|
|
||||||
const MASTODON_REDIRECT_URI = "http://localhost:7331/api/mastodon/oauth"
|
|
||||||
const MASTODON_SCOPES = "read write"
|
|
||||||
|
|
||||||
type MastodonApp struct {
|
|
||||||
Id string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
Website *string `json:"website"`
|
|
||||||
RedirectUri string `json:"redirect_uri"`
|
|
||||||
ClientId string `json:"client_id"`
|
|
||||||
ClientSecret string `json:"client_secret"`
|
|
||||||
VapidKey string `json:"vapid_key"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MastodonTokenResponse struct {
|
|
||||||
Type string `json:"token_type"`
|
|
||||||
Token string `json:"access_token"`
|
|
||||||
CreateAt int `json:"created_at"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type MastodonCookie struct {
|
|
||||||
App *MastodonApp `json:"app"`
|
|
||||||
Token *MastodonTokenResponse `json:"token"`
|
|
||||||
Instance *string `json:"instance_url,string"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a MastodonCookie) Validate() error {
|
|
||||||
u, err := url.Parse(*a.Instance)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
u.Path = "/api/v1/apps/verify_credentials"
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewMastodonApp(u url.URL) (MastodonApp, error) {
|
|
||||||
u.Path = "/api/v1/apps"
|
|
||||||
q := u.Query()
|
|
||||||
q.Add("client_name", MASTODON_APP_NAME)
|
|
||||||
q.Add("redirect_uris", MASTODON_REDIRECT_URI)
|
|
||||||
q.Add("scopes", MASTODON_SCOPES)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
res, err := http.Post(u.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
|
||||||
if err != nil {
|
|
||||||
return MastodonApp{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(res.Body)
|
|
||||||
if err != nil {
|
|
||||||
return MastodonApp{}, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if res.StatusCode != 200 {
|
|
||||||
return MastodonApp{}, errors.New(string(body))
|
|
||||||
}
|
|
||||||
|
|
||||||
var app MastodonApp
|
|
||||||
err = json.Unmarshal(body, &app)
|
|
||||||
|
|
||||||
return app, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func MastodonAppHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
error := internals.HttpErrorHelper(w)
|
|
||||||
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
error("Method not allowed", errors.New("method "+r.Method+" is not allowed"), http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
i := r.URL.Query().Get("instance-url")
|
|
||||||
if i == "" {
|
|
||||||
i = r.FormValue("instance-url")
|
|
||||||
if i == "" {
|
|
||||||
error(
|
|
||||||
"Bad request",
|
|
||||||
errors.New("Missing \"instance-url\" parameter"),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
u, err := url.Parse(i)
|
|
||||||
if error("Bad request\n\"instance-url\" is not a valid url", err, http.StatusBadRequest) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if u.Scheme == "" {
|
|
||||||
u, err = url.Parse("https://" + u.String())
|
|
||||||
if error("Bad request\n\"instance-url\" is not a valid url", err, http.StatusBadRequest) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rc, err := r.Cookie(MASTODON_COOKIE_NAME + "-" + u.Hostname())
|
|
||||||
if err != nil {
|
|
||||||
rc = &http.Cookie{
|
|
||||||
Name: MASTODON_COOKIE_NAME + u.Hostname(),
|
|
||||||
Value: fmt.Sprintf("{\"instance_url\": \"%s\"}", u.String()),
|
|
||||||
Path: "/",
|
|
||||||
Secure: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var c MastodonCookie
|
|
||||||
err = json.Unmarshal([]byte(rc.Value), &c)
|
|
||||||
if err != nil {
|
|
||||||
_ = json.Unmarshal([]byte("{\"instance_url\": \"%s\"}"), c)
|
|
||||||
}
|
|
||||||
|
|
||||||
if c.Instance == nil {
|
|
||||||
str := u.String()
|
|
||||||
c.Instance = &str
|
|
||||||
}
|
|
||||||
if c.App == nil {
|
|
||||||
a, err := NewMastodonApp(*u)
|
|
||||||
c.App = &a
|
|
||||||
if error("Internal Error\nerror trying to create new Mastodon application", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
v, err := json.Marshal(c)
|
|
||||||
rc.Value = string(v)
|
|
||||||
http.SetCookie(w, rc)
|
|
||||||
|
|
||||||
log.Print(rc)
|
|
||||||
|
|
||||||
q := u.Query()
|
|
||||||
q.Add("response_type", "code")
|
|
||||||
q.Add("client_id", c.App.ClientId)
|
|
||||||
q.Add("redirect_uri", MASTODON_REDIRECT_URI)
|
|
||||||
q.Add("scope", MASTODON_SCOPES)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
u.Path = "/oauth/authorize"
|
|
||||||
|
|
||||||
MastodonAppPage(*u).Render(context.Background(), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ MastodonAppPage(u url.URL) {
|
|
||||||
@layouts.Page("Project Extrovert") {
|
|
||||||
<dialog open>
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<p>Mastodon app created!</p>
|
|
||||||
</header>
|
|
||||||
<p>
|
|
||||||
An app for your interactions was created on { u.Hostname() },
|
|
||||||
Click "Ok" to authorize your account.
|
|
||||||
</p>
|
|
||||||
<footer>
|
|
||||||
<a href={ templ.SafeURL(u.String()) }>
|
|
||||||
<button>Ok</button>
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
@IndexPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func MastodonOAuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
var err error
|
|
||||||
error := internals.HttpErrorHelper(w)
|
|
||||||
|
|
||||||
var rc *http.Cookie
|
|
||||||
|
|
||||||
o := r.Header.Get("Origin")
|
|
||||||
u, err := url.Parse(o)
|
|
||||||
|
|
||||||
if err == nil && o != "" {
|
|
||||||
rc, err = r.Cookie(MASTODON_COOKIE_NAME + "-" + u.Hostname())
|
|
||||||
}
|
|
||||||
if err != nil || o == "" {
|
|
||||||
i := slices.IndexFunc(r.Cookies(), func(c *http.Cookie) bool {
|
|
||||||
return strings.HasPrefix(c.Name, MASTODON_COOKIE_NAME+"-")
|
|
||||||
})
|
|
||||||
rc = r.Cookies()[i]
|
|
||||||
}
|
|
||||||
|
|
||||||
var c MastodonCookie
|
|
||||||
err = json.Unmarshal([]byte(rc.Value), &c)
|
|
||||||
if error("Bad Request", err, http.StatusBadRequest) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
error(
|
|
||||||
"Bad request",
|
|
||||||
errors.New("Missing \"code\" parameter"),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
u.Path = "/oauth/token"
|
|
||||||
q := u.Query()
|
|
||||||
q.Add("grant_type", "authorization_code")
|
|
||||||
q.Add("client_id", c.App.ClientId)
|
|
||||||
q.Add("client_secret", c.App.ClientSecret)
|
|
||||||
q.Add("redirect_uri", MASTODON_REDIRECT_URI)
|
|
||||||
q.Add("scope", MASTODON_SCOPES)
|
|
||||||
q.Add("code", code)
|
|
||||||
u.RawQuery = q.Encode()
|
|
||||||
|
|
||||||
t, err := http.Post(u.String(), "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
|
||||||
if error("Error trying to request token from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := io.ReadAll(t.Body)
|
|
||||||
if error("Error trying to read response body from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
} else if t.StatusCode < 200 || t.StatusCode > 299 {
|
|
||||||
error(
|
|
||||||
"Error trying to request token from twitter, returned non-200 code",
|
|
||||||
errors.New(fmt.Sprintf("Code: %v, Return value: %s", t.StatusCode, string(b))),
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var res MastodonTokenResponse
|
|
||||||
err = json.Unmarshal(b, &res)
|
|
||||||
if error("Error trying to parse response body from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Token = &res
|
|
||||||
|
|
||||||
v, err := json.Marshal(c)
|
|
||||||
rc.Value = string(v)
|
|
||||||
http.SetCookie(w, rc)
|
|
||||||
|
|
||||||
MastodonOAuth().Render(context.Background(), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ MastodonOAuth() {
|
|
||||||
@layouts.Page("Project Extrovert") {
|
|
||||||
<dialog open>
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<p>Logged into Mastodon!</p>
|
|
||||||
</header>
|
|
||||||
<p>
|
|
||||||
Your account was succefully connected with project-extrovert!
|
|
||||||
Click "Ok" to return to the index page.
|
|
||||||
</p>
|
|
||||||
<footer>
|
|
||||||
<a href="/index.html">
|
|
||||||
<button>Ok</button>
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
@IndexPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/a-h/templ"
|
|
||||||
)
|
|
||||||
|
|
||||||
var ROUTES = []Route{
|
|
||||||
{
|
|
||||||
Pattern: "/index.html",
|
|
||||||
Static: true,
|
|
||||||
Page: IndexPage(),
|
|
||||||
Handler: IndexHandler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Pattern: "/api/twitter/oauth",
|
|
||||||
Static: false,
|
|
||||||
Page: TwitterOAuth(),
|
|
||||||
Handler: TwitterOAuthHandler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Pattern: "/api/mastodon/oauth",
|
|
||||||
Static: false,
|
|
||||||
Page: MastodonOAuth(),
|
|
||||||
Handler: MastodonOAuthHandler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Pattern: "/api/mastodon/apps",
|
|
||||||
Static: false,
|
|
||||||
Handler: MastodonAppHandler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Pattern: "/robots.txt",
|
|
||||||
Static: true,
|
|
||||||
Page: RobotsTxt(),
|
|
||||||
Handler: RobotsTxtHandler,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Pattern: "/ai.txt",
|
|
||||||
Static: true,
|
|
||||||
Page: AiTxt(),
|
|
||||||
Handler: AiTxtHandler,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
type RouteHandler = func(http.ResponseWriter, *http.Request)
|
|
||||||
|
|
||||||
type Route struct {
|
|
||||||
Pattern string
|
|
||||||
Static bool
|
|
||||||
Handler RouteHandler
|
|
||||||
Page templ.Component
|
|
||||||
}
|
|
||||||
|
|
||||||
func RegisterAllRoutes(routes []Route, s *http.ServeMux) {
|
|
||||||
for _, r := range routes {
|
|
||||||
s.HandleFunc(r.Pattern, r.Handler)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
44
routes/routes.go
Normal file
44
routes/routes.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"extrovert/internals"
|
||||||
|
"extrovert/templates/pages"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/a-h/templ"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewStaticPageHandler(c templ.Component) func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := c.Render(context.Background(), w)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
log.Fatalf("TODO-ERR trying to render static page:\n%s", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ROUTES = []internals.Route{
|
||||||
|
{
|
||||||
|
Pattern: "/index.html",
|
||||||
|
Static: true,
|
||||||
|
Page: pages.Homepage(),
|
||||||
|
Handler: NewStaticPageHandler(pages.Homepage()),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Pattern: "/robots.txt",
|
||||||
|
Static: true,
|
||||||
|
Page: RobotsTxt(),
|
||||||
|
Handler: RobotsTxtHandler,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Pattern: "/ai.txt",
|
||||||
|
Static: true,
|
||||||
|
Page: AiTxt(),
|
||||||
|
Handler: AiTxtHandler,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
package routes
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"fmt"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"extrovert/layouts"
|
|
||||||
"extrovert/internals"
|
|
||||||
)
|
|
||||||
|
|
||||||
type TwitterTokenResponse struct {
|
|
||||||
Type string `json:"token_type"`
|
|
||||||
Token string `json:"access_token"`
|
|
||||||
Expires int `json:"expires_in"`
|
|
||||||
Scope string `json:"scope"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func TwitterOAuthHandler(w http.ResponseWriter, r *http.Request) {
|
|
||||||
error := internals.HttpErrorHelper(w)
|
|
||||||
|
|
||||||
code := r.URL.Query().Get("code")
|
|
||||||
if code == "" {
|
|
||||||
error(
|
|
||||||
"Bad request",
|
|
||||||
errors.New("Missing \"code\" parameter"),
|
|
||||||
http.StatusBadRequest,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tReq := fmt.Sprintf("https://api.twitter.com/2/oauth2/token"+
|
|
||||||
"?grant_type=authorization_code"+
|
|
||||||
"&client_id=%s"+
|
|
||||||
"&code_verifier=challenge"+
|
|
||||||
"&code=%s"+
|
|
||||||
"&challenge_method=plain"+
|
|
||||||
"&redirect_uri=http://localhost:7331/api/oauth/twitter",
|
|
||||||
os.Getenv("TWITTER_CLIENT_ID"),
|
|
||||||
code,
|
|
||||||
)
|
|
||||||
|
|
||||||
t, err := http.Post(tReq, "application/x-www-form-urlencoded", bytes.NewReader([]byte("")))
|
|
||||||
if error("Error trying to request token from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
b, err := io.ReadAll(t.Body)
|
|
||||||
if error("Error trying to read response body from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
} else if t.StatusCode < 200 || t.StatusCode > 299 {
|
|
||||||
error(
|
|
||||||
"Error trying to request token from twitter, returned non-200 code",
|
|
||||||
errors.New(fmt.Sprintf("Code: %v, Return value: %s", t.StatusCode, string(b))),
|
|
||||||
http.StatusInternalServerError,
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var res TwitterTokenResponse
|
|
||||||
err = json.Unmarshal(b, &res)
|
|
||||||
if error("Error trying to parse response body from twitter", err, http.StatusInternalServerError) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c := internals.GetCookie("twitter-data", w, r)
|
|
||||||
c.Value = res.Token
|
|
||||||
http.SetCookie(w, c)
|
|
||||||
|
|
||||||
TwitterOAuth().Render(context.Background(), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ TwitterOAuth() {
|
|
||||||
@layouts.Page("Project Extrovert") {
|
|
||||||
<dialog open>
|
|
||||||
<article>
|
|
||||||
<header>
|
|
||||||
<p>Logged into Twitter!</p>
|
|
||||||
</header>
|
|
||||||
<p>
|
|
||||||
Your account was succefully connected with project-extrovert!
|
|
||||||
Click "Ok" to return to the index page.
|
|
||||||
</p>
|
|
||||||
<footer>
|
|
||||||
<a href="/index.html">
|
|
||||||
<button>Ok</button>
|
|
||||||
</a>
|
|
||||||
</footer>
|
|
||||||
</article>
|
|
||||||
</dialog>
|
|
||||||
@IndexPage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,11 @@
|
|||||||
package routes
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"extrovert/templates/layouts"
|
||||||
|
|
||||||
"extrovert/layouts"
|
|
||||||
"extrovert/components"
|
"extrovert/components"
|
||||||
"extrovert/internals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func IndexHandler(w http.ResponseWriter, r *http.Request) {
|
templ Homepage() {
|
||||||
_ = internals.GetCookie("twitter-data", w, r)
|
|
||||||
|
|
||||||
IndexPage().Render(context.TODO(), w)
|
|
||||||
}
|
|
||||||
|
|
||||||
templ IndexPage() {
|
|
||||||
@layouts.Page("Project Extrovert") {
|
@layouts.Page("Project Extrovert") {
|
||||||
<div style="max-width:50rem;">
|
<div style="max-width:50rem;">
|
||||||
<div style="display:flex;flex-direction:column;gap:1rem;">
|
<div style="display:flex;flex-direction:column;gap:1rem;">
|
||||||
26
templates/pages/redirect.templ
Normal file
26
templates/pages/redirect.templ
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package pages
|
||||||
|
|
||||||
|
import (
|
||||||
|
"extrovert/templates/layouts"
|
||||||
|
)
|
||||||
|
|
||||||
|
templ RedirectPopUp(title string, msg string, url templ.SafeURL) {
|
||||||
|
@layouts.Page("Project Extrovert") {
|
||||||
|
<dialog open>
|
||||||
|
<article>
|
||||||
|
<header>
|
||||||
|
<p>{ title }</p>
|
||||||
|
</header>
|
||||||
|
<p>
|
||||||
|
{ msg }
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<a href={ url }>
|
||||||
|
<button>Ok</button>
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</dialog>
|
||||||
|
@Homepage()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user