Add feedback form

Signed-off-by: Michael Mayer <michael@lastzero.net>
This commit is contained in:
Michael Mayer 2020-10-04 22:22:53 +02:00
parent 4fc693fb0b
commit b87e860444
16 changed files with 400 additions and 94 deletions

View file

@ -11,11 +11,11 @@
</v-flex>
<v-flex xs12 sm6 class="px-0 pb-2 body-1 text-xs-left text-sm-right">
<a href="https://docs.photoprism.org/credits/" class="secondary-dark--text"
<a href="https://docs.photoprism.org/credits/" class="primary--text text--darken-4"
target="_blank">Thank you</a> to everyone who made this possible!
<br>
<a href="https://raw.githubusercontent.com/photoprism/photoprism/develop/NOTICE"
class="secondary-dark--text" target="_blank">
class="primary--text text--darken-4" target="_blank">
3rd-party software packages</a>
</v-flex>
</v-layout>

View file

@ -363,7 +363,15 @@
<v-list-tile :to="{ name: 'about' }" :exact="true" @click="" class="nav-about">
<v-list-tile-content>
<v-list-tile-title>
<translate key="Help">Help</translate>
<translate>About</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>
<v-list-tile :to="{ name: 'feedback' }" :exact="true" @click="" v-show="!public && auth" class="nav-feedback">
<v-list-tile-content>
<v-list-tile-title>
<translate>Feedback</translate>
</v-list-tile-title>
</v-list-tile-content>
</v-list-tile>

View file

@ -10,10 +10,13 @@
#photoprism p a.highlight,
#photoprism #photoprism-help p a{
color: #222;
text-decoration: none;
text-shadow: -3px 0px 3px rgba(84, 226, 255, 0.15), 3px 0px 3px rgba(84, 226, 255, 0.15), 6px 0px 6px rgba(84, 226, 255, 0.15), -6px 0px 6px rgba(84, 226, 255, 0.15);
/* text-shadow: -3px 0px 3px rgba(255, 255, 0, 0.5), 3px 0px 3px rgba(255, 255, 0, 0.5), 6px 0px 6px rgba(255, 255, 0, 0.5), -6px 0px 6px rgba(255, 255, 0, 0.5); */
color: #4a148c;
}
#photoprism p a.highlight:hover,
#photoprism #photoprism-help p a:hover{
text-decoration: underline !important;
}
#photoprism .v-alert a {

View file

@ -257,7 +257,7 @@
const months = Info.months("long");
for (let i = 0; i < months.length; i++) {
result.push({"Month": i + 1, "Name": months[i]});
result.push({"Month": i + 1, "UserName": months[i]});
}
return result;

View file

@ -236,3 +236,12 @@ export const Colors = () => [
{"Example": "#9E9E9E", "Name": $gettext("Grey"), "Slug": "grey"},
{"Example": "#212121", "Name": $gettext("Black"), "Slug": "black"},
];
export const FeedbackCategories = () => [
{"value": "help", "text": $gettext("Customer Support")},
{"value": "feedback", "text": $gettext("Product Feedback")},
{"value": "feature", "text": $gettext("Feature Request")},
{"value": "bug", "text": $gettext("Bug Report")},
{"value": "donations", "text": $gettext("Donations")},
{"value": "other", "text": $gettext("Other")},
];

View file

@ -2,7 +2,7 @@
<div class="p-page p-page-about">
<v-toolbar flat color="secondary">
<v-toolbar-title>
<translate>Whatever it is, we'd love to hear from you!</translate>
<translate>Contributors</translate>
</v-toolbar-title>
<v-spacer></v-spacer>
@ -12,12 +12,71 @@
</v-btn>
</v-toolbar>
<v-container fluid class="pa-4">
<p class="body-1">Feel free to use the
<a target="_blank" href="https://gitter.im/browseyourlife/community" class="highlight">chat</a>
or send an e-mail to <a target="_blank" href="mailto:hello@photoprism.org" class="highlight">hello@photoprism.org</a>
if you have questions, want to support our work, or just want to say hello.</p>
<h3 class="py-2 body-2">Andrea Ceroni</h3>
<p class="body-1">Andrea is a data scientist specialized in temporal information retrieval and machine
learning.
He holds a PhD in Computer Science from the Leibniz University of Hannover (L3S Research Center) and wrote
numerous papers on topics such as
<a target="_blank"
href="https://www.researchgate.net/profile/Andrea_Ceroni/publication/323222448_Personal_Photo_Management_and_Preservation/links/5a995f8da6fdcc3cbac8fa59/Personal-Photo-Management-and-Preservation.pdf"
class="primary--text text--darken-4">Personal
Photo Management and Preservation</a>
and <a target="_blank" href="https://www.iti.gr/~bmezaris/publications/hmmp@icme2015_2_preprint.pdf"
class="primary--text text--darken-4">Photo Selection Models for
Personal Photo Collections</a>.</p>
<p class="body-1">You can find him on <a target="_blank"
href="https://scholar.google.de/citations?user=JHsQY5YAAAAJ&amp;hl=en"
class="primary--text text--darken-4">Google Scholar</a> and <a
target="_blank"
href="https://www.linkedin.com/in/andrea-ceroni/" class="primary--text text--darken-4">LinkedIn</a></p>
<p>
<h3 class="py-2 body-2">Theresa Gresch</h3>
<p class="body-1">Theresa works as a freelance product manager and developer in Berlin. She has a Master's in
Neurobiology
and aims to dive deeper into machine learning while working on this project.</p>
<p class="body-1">You can find her on <a target="_blank" href="https://github.com/graciousgrey"
class="primary--text text--darken-4">GitHub</a> and <a
target="_blank" href="https://www.linkedin.com/in/theresa-gresch-886924103/"
class="primary--text text--darken-4">LinkedIn</a></p>
<h3 class="py-2 body-2">Michael Mayer</h3>
<p class="body-1">Michael learned coding on an Atari 1040 ST and started his first open-source projects in
the 90s. He has more than 25 years of experience in building Web applications.
His motivation is to explore the latest technologies and build an amazing product outside the constraints of a
corporate environment.</p>
<p class="body-1">You can find him on <a target="_blank" href="https://github.com/lastzero"
class="primary--text text--darken-4">GitHub</a>,
<a target="_blank" href="https://www.linkedin.com/in/lastzero/"
class="primary--text text--darken-4">LinkedIn</a>
and <a target="_blank" href="https://twitter.com/lastzero" class="primary--text text--darken-4">Twitter</a>
</p>
<h3 class="py-2 body-2">Guy Sheffer</h3>
<p class="body-1">Known as <a target="_blank" href="https://github.com/guysoft">GuySoft</a> on the web. Active
developer in
the Free Software and Maker community.
Creator of <a target="_blank" href="https://github.com/guysoft/OctoPi" class="primary--text text--darken-4">OctoPi</a>
and
<a target="_blank" href="https://github.com/guysoft/FullPageOS"
class="primary--text text--darken-4">FullPageOS</a>,
which have hundreds of thousands of downloads. Raspberry Pi distro expert.
Currently VP R&amp;D and Co-Founder at <a target="_blank" href="https://shapedo.com/"
class="primary--text text--darken-4">ShapeDo</a>.</p>
<p class="body-2 pb-1">
<a target="_blank" href="https://github.com/photoprism/photoprism/graphs/contributors"
class="primary--text text--darken-4">...and many more</a>
</p>
<h2 class="py-2 subheading">
<translate>Trademarks</translate>
</h2>
<p class="body-1 pb-2">
<translate>PhotoPrism® is a registered trademark of Michael Mayer.</translate>
<translate>You may use it as required to describe our software, run your own server, for educational purposes, but not for offering commercial goods, products, or services without prior written permission. In other words, please ask.</translate>
</p>
<p class="text-xs-center pt-3 mb-2">
<router-link to="/about/license">
<img src="/static/img/badge-agpl.svg" alt="License AGPL v3" style="max-width:100%;"/>
</router-link>
@ -25,62 +84,9 @@
alt="Documentation"
style="max-width:100%;"></a>
<a target="_blank" href="https://gitter.im/browseyourlife/community" rel="nofollow"><img
src="/static/img/badge-chat.svg" alt="Community Chat" style="max-width:100%;"></a>
src="/static/img/badge-chat.svg" alt="Community Chat" style="max-width:100%;"></a>
<a target="_blank" href="https://twitter.com/browseyourlife" rel="nofollow"><img
src="/static/img/badge-twitter.svg" alt="Twitter" style="max-width:100%;"></a>
</p>
<h2 class="py-2 subheading"><translate>Who we are</translate></h2>
<h3 class="py-2 body-2">Andrea Ceroni</h3>
<p class="body-1">Andrea is a data scientist specialized in temporal information retrieval and machine
learning.
He holds a PhD in Computer Science from the Leibniz University of Hannover (L3S Research Center) and wrote
numerous papers on topics such as
<a target="_blank" href="https://www.researchgate.net/profile/Andrea_Ceroni/publication/323222448_Personal_Photo_Management_and_Preservation/links/5a995f8da6fdcc3cbac8fa59/Personal-Photo-Management-and-Preservation.pdf" class="highlight">Personal
Photo Management and Preservation</a>
and <a target="_blank" href="https://www.iti.gr/~bmezaris/publications/hmmp@icme2015_2_preprint.pdf" class="highlight">Photo Selection Models for
Personal Photo Collections</a>.</p>
<p class="body-1">You can find him on <a target="_blank"
href="https://scholar.google.de/citations?user=JHsQY5YAAAAJ&amp;hl=en" class="highlight">Google Scholar</a> and <a target="_blank"
href="https://www.linkedin.com/in/andrea-ceroni/" class="highlight">LinkedIn</a></p>
<h3 class="py-2 body-2">Theresa Gresch</h3>
<p class="body-1">Theresa works as a freelance product manager and developer in Berlin. She has a Master's in
Neurobiology
and aims to dive deeper into machine learning while working on this project.</p>
<p class="body-1">You can find her on <a target="_blank" href="https://github.com/graciousgrey" class="highlight">GitHub</a> and <a
target="_blank" href="https://www.linkedin.com/in/theresa-gresch-886924103/" class="highlight">LinkedIn</a></p>
<h3 class="py-2 body-2">Michael Mayer</h3>
<p class="body-1">Michael learned coding on an Atari 1040 ST and started his first open-source projects in
the 90s. He has more than 20 years of experience in building Web applications.
His motivation is to explore the latest technologies and build an amazing product outside the constraints of a
corporate environment.</p>
<p class="body-1">You can find him on <a target="_blank" href="https://github.com/lastzero" class="highlight">GitHub</a>,
<a target="_blank" href="https://www.linkedin.com/in/lastzero/" class="highlight">LinkedIn</a>
and <a target="_blank" href="https://twitter.com/lastzero" class="highlight">Twitter</a>
</p>
<h3 class="py-2 body-2">Guy Sheffer</h3>
<p class="body-1">Known as <a target="_blank" href="https://github.com/guysoft">GuySoft</a> on the web. Active
developer in
the Free Software and Maker community.
Creator of <a target="_blank" href="https://github.com/guysoft/OctoPi" class="highlight">OctoPi</a> and
<a target="_blank" href="https://github.com/guysoft/FullPageOS" class="highlight">FullPageOS</a>,
which have hundreds of thousands of downloads. Raspberry Pi distro expert.
Currently VP R&amp;D and Co-Founder at <a target="_blank" href="https://shapedo.com/" class="highlight">ShapeDo</a>.</p>
<h3 class="pb-1 body-2">...and many more</h3>
<p class="body-1">Thank you to
<a target="_blank" href="https://github.com/photoprism/photoprism/graphs/contributors" class="highlight">everyone who
contributed</a> to this project!</p>
<h2 class="py-2 subheading"><translate>Trademarks</translate></h2>
<p class="body-1">
<translate>PhotoPrism® is a registered trademark of Michael Mayer.</translate>
<translate>You may use it as required to describe our software, run your own server, for educational purposes, but not for offering commercial goods, products, or services without prior written permission. In other words, please ask.</translate>
src="/static/img/badge-twitter.svg" alt="Twitter" style="max-width:100%;"></a>
</p>
</v-container>
@ -89,11 +95,11 @@
</template>
<script>
export default {
name: 'p-page-about',
data() {
return {};
},
methods: {},
};
export default {
name: 'p-page-about',
data() {
return {};
},
methods: {},
};
</script>

View file

@ -0,0 +1,121 @@
<template>
<div class="p-page p-page-support">
<v-toolbar flat color="secondary">
<v-toolbar-title v-if="sent">
<translate>Your message has been sent</translate>
</v-toolbar-title>
<v-toolbar-title v-else>
<translate>Whatever it is, we'd love to hear from you!</translate>
</v-toolbar-title>
<!-- v-spacer></v-spacer>
<v-btn icon href="https://github.com/photoprism/photoprism" target="_blank" class="action-github" title="GitHub">
<img src="/static/brands/github.svg" width="24" alt="GitHub">
</v-btn -->
</v-toolbar>
<v-container fluid class="pa-4" v-if="sent">
<p class="body-1">We'll get back to you as soon as possible!</p>
</v-container>
<v-form autocomplete="off" class="pa-3" ref="form"
v-model="valid"
lazy-validation v-else>
<v-layout row wrap>
<!-- v-flex xs12 sm6 lg4 xl2 grow class="pa-2">
<v-text-field :required="true" hide-details
v-model="form.Subject" browser-autocomplete="off"
:label="$gettext('Subject')"></v-text-field>
</v-flex -->
<v-flex xs12 class="pa-2">
<v-select
:disabled="busy"
:items="options.FeedbackCategories()"
:label="$gettext('Category')"
color="secondary-dark"
background-color="secondary-light"
v-model="form.Category"
flat solo hide-details required
browser-autocomplete="off"
class="input-category"
:rules="[v => !!v || $gettext('Required')]"
></v-select>
</v-flex>
<v-flex xs12 class="pa-2">
<v-textarea required auto-grow flat solo hide-details browser-autocomplete="off"
v-model="form.Message" rows="10" :rules="[v => !!v || $gettext('Required')]"
:label="$gettext('Your Message')"></v-textarea>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field flat solo hide-details required browser-autocomplete="off"
color="secondary-dark" :rules="[v => !!v || $gettext('Required')]"
background-color="secondary-light"
:label="$gettext('E-Mail')" type="email" v-model="form.UserEmail">
</v-text-field>
</v-flex>
<v-flex xs12 sm6 class="pa-2">
<v-text-field flat solo hide-details browser-autocomplete="off"
color="secondary-dark"
background-color="secondary-light"
:label="$gettext('Name')" type="text" v-model="form.UserName">
</v-text-field>
</v-flex>
<v-flex xs12 grow class="px-2 py-1">
<v-btn color="secondary-dark"
class="white--text ml-0"
depressed
:disabled="!form.Category || !form.Message || !form.UserEmail"
@click.stop="send">
<translate>Send</translate>
<v-icon right dark>send</v-icon>
</v-btn>
</v-flex>
</v-layout>
</v-form>
<p-about-footer></p-about-footer>
</div>
</template>
<script>
import * as options from "options/options";
import Api from "../../common/api";
export default {
name: 'p-page-support',
data() {
return {
sent: false,
busy: false,
valid: false,
options: options,
form: {
Category: "",
Message: "",
UserName: "",
UserEmail: "",
UserAgent: navigator.userAgent,
UserLocales: navigator.language,
}
};
},
methods: {
send() {
if (this.$refs.form.validate()) {
Api.post("feedback", this.form).then(() => {
this.$notify.success(this.$gettext("Message sent"));
this.sent = true;
});
} else {
this.$notify.error(this.$gettext("All fields are required"));
}
},
},
};
</script>

View file

@ -41,6 +41,7 @@ import Settings from "pages/settings.vue";
import Login from "pages/login.vue";
import Discover from "pages/discover.vue";
import About from "pages/about/about.vue";
import Feedback from "pages/about/feedback.vue";
import License from "pages/about/license.vue";
import Help from "pages/help.vue";
import {$gettext} from "common/vm";
@ -59,6 +60,12 @@ export default [
component: About,
meta: {title: c.name, auth: false},
},
{
name: "feedback",
path: "/feedback",
component: Feedback,
meta: {title: c.name, auth: false},
},
{
name: "license",
path: "/about/license",

View file

@ -22,4 +22,5 @@ const (
ResourcePeople Resource = "people"
ResourcePhotos Resource = "photos"
ResourcePlaces Resource = "places"
ResourceFeedback Resource = "feedback"
)

47
internal/api/feedback.go Normal file
View file

@ -0,0 +1,47 @@
package api
import (
"github.com/photoprism/photoprism/internal/service"
"net/http"
"github.com/photoprism/photoprism/internal/acl"
"github.com/photoprism/photoprism/internal/form"
"github.com/photoprism/photoprism/internal/i18n"
"github.com/gin-gonic/gin"
)
// POST /api/v1/feedback
func SendFeedback(router *gin.RouterGroup) {
router.POST("/feedback", func(c *gin.Context) {
s := Auth(SessionID(c), acl.ResourceFeedback, acl.ActionCreate)
if s.Invalid() {
AbortUnauthorized(c)
return
}
conf := service.Config()
conf.UpdatePro()
var f form.Feedback
if err := c.BindJSON(&f); err != nil {
AbortBadRequest(c)
return
}
if f.Empty() {
Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected)
return
}
if err := conf.Pro().SendFeedback(f); err != nil {
log.Error(err)
AbortSaveFailed(c)
return
}
c.JSON(http.StatusOK, gin.H{"code": http.StatusOK})
})
}

View file

@ -26,9 +26,6 @@ type ClientConfig struct {
ReadOnly bool `json:"readonly"`
UploadNSFW bool `json:"uploadNSFW"`
Public bool `json:"public"`
Pro bool `json:"pro"`
Sponsor bool `json:"sponsor"`
Contributor bool `json:"contributor"`
Experimental bool `json:"experimental"`
DisableSettings bool `json:"disableSettings"`
AlbumCategories []string `json:"albumCategories"`
@ -37,7 +34,7 @@ type ClientConfig struct {
Lenses []entity.Lens `json:"lenses"`
Countries []entity.Country `json:"countries"`
Thumbs []Thumb `json:"thumbs"`
ApiKey string `json:"apiKey"`
Status string `json:"status"`
MapKey string `json:"mapKey"`
DownloadToken string `json:"downloadToken"`
PreviewToken string `json:"previewToken"`
@ -144,7 +141,7 @@ func (c *Config) PublicConfig() ClientConfig {
DisableSettings: c.SettingsHidden(),
Public: c.Public(),
Experimental: c.Experimental(),
ApiKey: "",
Status: "",
MapKey: "",
Thumbs: Thumbs,
Colors: colors.All.List(),
@ -187,7 +184,7 @@ func (c *Config) GuestConfig() ClientConfig {
Experimental: false,
Colors: colors.All.List(),
Thumbs: Thumbs,
ApiKey: c.Pro().ApiKey(),
Status: c.Pro().Status,
MapKey: c.Pro().MapKey(),
DownloadToken: c.DownloadToken(),
PreviewToken: c.PreviewToken(),
@ -221,7 +218,7 @@ func (c *Config) UserConfig() ClientConfig {
Experimental: c.Experimental(),
Colors: colors.All.List(),
Thumbs: Thumbs,
ApiKey: c.Pro().ApiKey(),
Status: c.Pro().Status,
MapKey: c.Pro().MapKey(),
DownloadToken: c.DownloadToken(),
PreviewToken: c.PreviewToken(),

View file

@ -269,6 +269,19 @@ func (c *Config) OriginalsLimit() int64 {
return c.params.OriginalsLimit * 1024 * 1024
}
// UpdatePro updates photoprism.pro api credentials.
func (c *Config) UpdatePro() {
p := c.ProConfigFile()
if err := c.pro.Refresh(); err != nil {
log.Errorf("pro: %s", err)
} else if err := c.pro.Save(p); err != nil {
log.Errorf("pro: %s", err)
} else {
c.pro.Propagate()
}
}
// initPro initializes photoprism.pro api credentials.
func (c *Config) initPro() {
c.pro = pro.NewConfig(c.Version())
@ -290,13 +303,7 @@ func (c *Config) initPro() {
for {
select {
case <-ticker.C:
if err := c.pro.Refresh(); err != nil {
log.Errorf("pro: %s", err)
} else if err := c.pro.Save(p); err != nil {
log.Errorf("pro: %s", err)
} else {
c.pro.Propagate()
}
c.UpdatePro()
}
}
}()

15
internal/form/feedback.go Normal file
View file

@ -0,0 +1,15 @@
package form
// Feedback represents support requests / customer feedback.
type Feedback struct {
Category string `json:"Category"`
Message string `json:"Message"`
UserName string `json:"UserName"`
UserEmail string `json:"UserEmail"`
UserAgent string `json:"UserAgent"`
UserLocales string `json:"UserLocales"`
}
func (f Feedback) Empty() bool {
return len(f.Category) < 1 || len(f.Message) < 3 || len(f.UserEmail) < 5
}

View file

@ -41,11 +41,6 @@ func NewConfig(version string) *Config {
}
}
// ApiKey returns the photoprism.pro api key.
func (c *Config) ApiKey() string {
return c.Key
}
// MapKey returns the maps api key.
func (c *Config) MapKey() string {
if sess, err := c.DecodeSession(); err != nil {

89
internal/pro/feedback.go Normal file
View file

@ -0,0 +1,89 @@
package pro
import (
"bytes"
"encoding/json"
"fmt"
"github.com/photoprism/photoprism/internal/form"
"net/http"
"runtime"
"time"
)
var FeedbackURL = ApiURL + "/%s/feedback"
type Feedback struct {
Key string `json:"ApiKey"`
ClientVersion string `json:"ClientVersion"`
ClientOS string `json:"ClientOS"`
ClientArch string `json:"ClientArch"`
ClientCPU int `json:"ClientCPU"`
Category string `json:"Category"`
Message string `json:"Message"`
UserName string `json:"UserName"`
UserEmail string `json:"UserEmail"`
UserAgent string `json:"UserAgent"`
}
// NewFeedback creates a new photoprism.pro key request instance.
func NewFeedback(version string) *Feedback {
return &Feedback{
ClientVersion: version,
ClientOS: runtime.GOOS,
ClientArch: runtime.GOARCH,
ClientCPU: runtime.NumCPU(),
}
}
func (c *Config) SendFeedback(f form.Feedback) (err error) {
feedback := NewFeedback(c.Version)
feedback.Category = f.Category
feedback.Message = f.Message
feedback.UserName = f.UserName
feedback.UserEmail = f.UserEmail
feedback.UserAgent = f.UserAgent
feedback.Key = c.Key
client := &http.Client{Timeout: 60 * time.Second}
url := fmt.Sprintf(FeedbackURL, c.Key)
method := http.MethodPost
var req *http.Request
log.Debugf("pro: sending feedback")
if j, err := json.Marshal(feedback); err != nil {
return err
} else if req, err = http.NewRequest(method, url, bytes.NewReader(j)); err != nil {
return err
}
req.Header.Add("Accept-Language", f.UserLocales)
req.Header.Add("Content-Type", "application/json")
var r *http.Response
for i := 0; i < 3; i++ {
r, err = client.Do(req)
if err == nil {
break
}
}
if err != nil {
log.Errorf("pro: %s", err.Error())
return err
} else if r.StatusCode >= 400 {
err = fmt.Errorf("sending feedback failed with code %d", r.StatusCode)
return err
}
err = json.NewDecoder(r.Body).Decode(c)
if err != nil {
log.Errorf("pro: %s", err.Error())
return err
}
return nil
}

View file

@ -111,6 +111,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
api.SaveSettings(v1)
api.ChangePassword(v1)
api.GetErrors(v1)
api.SendFeedback(v1)
api.GetSvg(v1)