diff --git a/internal/webserver/handler_search.go b/internal/webserver/handler_search.go
new file mode 100644
index 0000000..7a0005f
--- /dev/null
+++ b/internal/webserver/handler_search.go
@@ -0,0 +1,58 @@
+package webserver
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "gitlab.com/offline-twitter/twitter_offline_engine/pkg/persistence"
+)
+
+func (app *Application) Search(w http.ResponseWriter, r *http.Request) {
+ app.traceLog.Printf("'Search' handler (path: %q)", r.URL.Path)
+
+ search_text := strings.Trim(r.URL.Path, "/")
+ if search_text == "" {
+ // Redirect GET param "q" to use a URL param instead
+ search_text = r.URL.Query().Get("q")
+ if search_text == "" {
+ app.error_400_with_message(w, "Empty search query")
+ return
+ // TODO: return an actual page
+ }
+ http.Redirect(w, r, fmt.Sprintf("/search/%s", url.PathEscape(search_text)), 302)
+ return
+ }
+
+ c, err := persistence.NewCursorFromSearchQuery(search_text)
+ if err != nil {
+ app.error_400_with_message(w, err.Error())
+ return
+ // TODO: return actual page
+ }
+ err = parse_cursor_value(&c, r)
+ if err != nil {
+ app.error_400_with_message(w, "invalid cursor (must be a number)")
+ return
+ }
+
+ feed, err := app.Profile.NextPage(c)
+ if err != nil {
+ if errors.Is(err, persistence.ErrEndOfFeed) {
+ // TODO
+ } else {
+ panic(err)
+ }
+ }
+
+ data := UserProfileData{Feed: feed} // TODO: wrong struct
+
+ if r.Header.Get("HX-Request") == "true" && c.CursorPosition == persistence.CURSOR_MIDDLE {
+ // It's a Show More request
+ app.buffered_render_tweet_htmx(w, "timeline", data)
+ } else {
+ app.buffered_render_tweet_page(w, "tpl/search.tpl", data)
+ }
+}
diff --git a/internal/webserver/server.go b/internal/webserver/server.go
index 1f0daa4..6c3c44b 100644
--- a/internal/webserver/server.go
+++ b/internal/webserver/server.go
@@ -118,6 +118,8 @@ func (app *Application) ServeHTTP(w http.ResponseWriter, r *http.Request) {
app.UserFollow(w, r)
case "unfollow":
app.UserUnfollow(w, r)
+ case "search":
+ http.StripPrefix("/search", http.HandlerFunc(app.Search)).ServeHTTP(w, r)
default:
app.UserFeed(w, r)
}
diff --git a/internal/webserver/server_test.go b/internal/webserver/server_test.go
index b8c4a91..2537ed8 100644
--- a/internal/webserver/server_test.go
+++ b/internal/webserver/server_test.go
@@ -1,10 +1,13 @@
package webserver_test
import (
+ "testing"
+
+ "fmt"
"net/http"
"net/http/httptest"
+ "net/url"
"strings"
- "testing"
"github.com/andybalholm/cascadia"
"github.com/stretchr/testify/assert"
@@ -158,6 +161,53 @@ func TestTimelineWithCursorBadNumber(t *testing.T) {
require.Equal(resp.StatusCode, 400)
}
+// Search page
+// -----------
+
+func TestSearchQueryStringRedirect(t *testing.T) {
+ assert := assert.New(t)
+
+ // With a cursor but it sucks
+ resp := do_request(httptest.NewRequest("GET", "/search?q=asdf", nil))
+ assert.Equal(resp.StatusCode, 302)
+ assert.Equal(resp.Header.Get("Location"), "/search/asdf")
+}
+
+func TestSearch(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+
+ resp := do_request(httptest.NewRequest("GET", fmt.Sprintf("/search/%s", url.PathEscape("to:spacex to:covfefeanon")), nil))
+ require.Equal(resp.StatusCode, 200)
+
+ root, err := html.Parse(resp.Body)
+ require.NoError(err)
+ title_node := cascadia.Query(root, selector("title"))
+ assert.Equal(title_node.FirstChild.Data, "Offline Twitter | Search")
+
+ tweet_nodes := cascadia.QueryAll(root, selector(".timeline > .tweet"))
+ assert.Len(tweet_nodes, 1)
+}
+
+func TestSearchWithCursor(t *testing.T) {
+ assert := assert.New(t)
+ require := require.New(t)
+
+ // First, without the cursor
+ resp := do_request(httptest.NewRequest("GET", "/search/who%20are", nil))
+ require.Equal(resp.StatusCode, 200)
+ root, err := html.Parse(resp.Body)
+ require.NoError(err)
+ assert.Len(cascadia.QueryAll(root, selector(".timeline > .tweet")), 3)
+
+ // Add a cursor with the 1st tweet's posted_at time
+ resp = do_request(httptest.NewRequest("GET", "/search/who%20are?cursor=1628979529", nil))
+ require.Equal(resp.StatusCode, 200)
+ root, err = html.Parse(resp.Body)
+ require.NoError(err)
+ assert.Len(cascadia.QueryAll(root, selector(".timeline > .tweet")), 2)
+}
+
// Tweet Detail page
// -----------------
diff --git a/internal/webserver/static/styles.css b/internal/webserver/static/styles.css
index f01bd87..2e811aa 100644
--- a/internal/webserver/static/styles.css
+++ b/internal/webserver/static/styles.css
@@ -381,6 +381,10 @@ svg {
left: 50%;
transform: translate(-50%, -50%);
}
+.top-bar form {
+ flex-grow: 1;
+ display: flex;
+}
.search-bar {
flex-grow: 1;
}
diff --git a/internal/webserver/tpl/includes/base.tpl b/internal/webserver/tpl/includes/base.tpl
index 8bd3f19..8848d9b 100644
--- a/internal/webserver/tpl/includes/base.tpl
+++ b/internal/webserver/tpl/includes/base.tpl
@@ -14,7 +14,9 @@
-
+